Skip to content

0.2.0: hold the Obtainium parity work to Tern's bar - #3

Merged
munzzyy merged 72 commits into
mainfrom
fix/integrate
Sep 30, 2026
Merged

munzzyy merged 72 commits into
mainfrom
fix/integrate

Conversation

@munzzyy

@munzzyy munzzyy commented Sep 30, 2026

Copy link
Copy Markdown
Owner

The first draft of Obtainium parity (#1) is now held to the rest of Tern. This is 0.2.0.

Sources

  • Sites that offer modified apps (LiteAPKs, Apk4Free, RockMods, Farsroid) and stores that only answer their own app (Uptodown, RuStore, CoolApk, which needed keys lifted from those apps) are refused by host. Russia's national root CA and the rustore.ru config that turned certificate transparency off are gone.
  • The other stores sit behind "Third-party stores", off by default. Apps from them wait, paused, while it is off.
  • Every source is asked as Tern. HonestIdentityTest fails on another app's User-Agent, id or a signing key in a source.
  • Built-in certificates are found by package for stores and web pages, so a well known app added from a mirror is held to its developer's key.

Safety

  • Only Tern's own shortcuts can start Update all, Add or a check. Links from outside ask first.
  • Downgrades only for a release picked by hand, never below what the source named.
  • v0.1.0 settings and web page apps keep their meaning. Pins survive an uninstall elsewhere, and archived apps stay.
  • Shared logs and crash reports are scrubbed, exports carry no header that could hold a key, and the network door check covers more ways out.
  • A copy F-Droid installed follows F-Droid for its own updates.

Proof

  • Core 1137 and app 740 unit tests, release build, lint and every tools check.
  • Device suites: Android 16 phone 306 passed and TV 14 305 passed, 0 failed on either.
  • Every kept source was checked live on Android itself.
  • Shizuku and Dhizuku installed and updated silently on an Android 13 emulator.
  • One open item: GateTest.aSplitBundleInstallsAsOneSession fails in full runs on Android 10 when Android delivers the session result about a minute late. It passes alone and on the phone and TV.

The token went in as a plain Authorization header, which the network
door drops on purpose, so a private Forgejo or Gitea project never
showed its page. It now goes the way the GitHub and GitLab ones do,
to that host only.
Only Tern's own messages went through Scrub. Other entries kept whole
addresses with their queries. The crash report kept every cause
message. A signed link or a key in a query left the phone when the
person shared either one. Now every message is scrubbed on its way out
and the crash report is scrubbed a line at a time. A report an older
build wrote is scrubbed before it is shown.

Scrub also takes out Cookie headers, session ids and short passwords.
The settings link already kept only User-Agent, Accept, Accept-Language
and Referer. Exports, the kept export and a file shared with another
app wrote every stored header, so an X-Api-Key went along when someone
shared one app. The rule now lives in RequestHeaders and both export
formats use it. The export summary says so.
Any app could take one of the four verifier package names and get the
checked file of every first install, then show whatever verdict it
liked. Each verifier is now pinned to the certificate its makers
publish, which Privacy Guides' verified list also has. The hand-off
and the "Check with" button both skip an app that fails the check,
and the hand-off is off until the person turns it on.
The window was made at the size the header names before a byte came
out. That is 64 MiB for xz -9, and a TV with a small heap threw
OutOfMemoryError on a tiny file. The window now starts at 1 MiB and
doubles until it wraps. When the device has no room left the data is
refused like any other it cannot read.
The check looked for openConnection, sockets and name lookups only.
URL.readText, DownloadManager, HttpEngine, a web view or DnsResolver
all went through it clean. It now fails on those and on the usual
HTTP libraries. tools/tests/network-doors holds one file per way, and
its runner proves in CI that each one fails the check and the tree
passes.
The docs and the setting said only that a hubproxy sees the requests.
It serves the release list, the digest and the file, and Tern passes
them on as GitHub's. So the first install of an app through it trusts
the hubproxy, and pinning does not reach its host. The setting and
SECURITY-MODEL.md now say so. A digest that came through one is shown
as passed on by that host.
Until !50599 is merged it pins one version and one commit. A release
that skips it leaves F-Droid building an older Tern. The step names
the fields to change and wants the full commit hash, since the build
has to reproduce the signed APK.
In v0.1.0 those two switches put their limits on the periodic check.
PR #1 kept the keys but made them hold only installs, so someone who
chose Wi-Fi only started checking over mobile data after updating.
Checks and installs now have a switch each. Settings v0.1.0 wrote (it
alone kept checkEveryHours) carry their old value into the check
switches too.
The job JobScheduler runs once Wi-Fi or the charger is there asked the
device again and could get another answer. A charger paused at 80 %
counts as charging for JobScheduler and not for BatteryManager. The
job then held the installs, set itself again at once and checked the
whole list each time. It now installs from the last check without
asking again, never sets itself, and waits 15 minutes at least.
With Let Me Downgrade and the setting on, the background check and
Update all could also install an older file. A store that names a
higher versionCode than it serves could then roll an app back to a
signed but older build. Only a release picked from an app's history
may go back now. Even then the gate refuses a file older than the
versionCode its source named.
LiteAPKs, Apk4Free, RockMods and Farsroid offer unlocked "mod" builds.
Farsroid mixes them with the originals under names like ADM-Pro and
CapCut-Pro. No rule could pick the real file there. RuStore, Uptodown
and CoolApk answer only their own apps. Reading them meant shipping keys
from those apps and signing requests as them. All seven are gone. Their
hosts are refused, also as plain web pages. An import that names them
keeps every other app and lists these as skipped with the reason.

The Russian state root CA and the rustore.ru block without Certificate
Transparency go with RuStore. A test parses both network configs.

The stores that stay get the same User-Agent as every other request.
APKPure is read from its site now, since its API wants the id of the
APKPure app. Huawei takes the handshake without the AppGallery package
or a phone model. The Galaxy Store gets one fixed model and region. A
custom User-Agent header is refused. HonestIdentityTest fails if any of
this comes back.
A check asked for every Android upload of a game as the download button
does, then read its first byte for the name and size. That is up to 17
requests per check, and itch.io may count each one as a download. A
check now reads the page alone and lists each upload under the name the
page gives. resolve() already asks for a fresh address just before the
download, and the size comes from that one-byte read on the app page.
PR #1 changed what a web page app does but kept schema 1. In 0.1.0 a
step took the first match in page order, the last page gave the
highest version, and the version pattern read the guessed version.
Every row also held a wait of 0 that nobody chose.

A schema 1 row with no release order is from 0.1.0. Its steps now
keep page order and take the first link, its wait follows the setting,
and a web page app gets a highestVersion switch. The schema is now 2.
Four rows of chips were plain FlowRows: the categories and the sources
in the filter dialog, the categories on the page of an app and the
places a search looks in. Once they wrapped, right from the end of a
line jumped to whatever lay to the right further down. On the television
image it landed on the tenth chip.

ChoiceChips already handled this. Its key handling is now ChipLines. It
draws any list it is given and all four use it. The search places are
ChoiceChips now too, so they draw focus like the others.
"Remove apps uninstalled elsewhere" deleted the row with its pins and
repository key. Someone who uninstalled to reinstall had the next file
served pinned as if it were new. Android 15 archiving also counted as
an uninstall. Archived apps now stay. A dropped app's pins are kept by
source and come back when the same source is added again. Removing by
hand in Tern still forgets them.
The eight stores Tern still reads serve their own copies of apps through
interfaces made for their own apps and sites. Nothing said so before an
app was added, and nothing could turn them off.

The new switch sits under Settings and Network. While it is off none of
their hosts is asked. A pasted store address shows a card that says what
the stores are and offers one button to turn them on. Search and Read as
leave them out. Apps from them stay in the list as paused with a note on
the row and the page. An export does not carry the switch, so no file
or link turns it on. itch.io and Telegram and Neutron Code are the
developers' own channels and stay open.

The app page now has the origin note of the Add preview. Sites Tern
refuses get their reason in the person's language.
Built-in pins were looked up by the address of the app. Aegis added
from APKPure got none. Its first install pinned whatever the store
served.

Now an app from a store, a web page or a direct link is held to every
certificate the starter list carries for its package. That is set when
the app is added or imported, or when a check first learns the package.
A mismatch blocks the first install with the built-in pin message. The
pin can still be removed on the app page. Forges keep the old rule
because a fork may keep the package. F-Droid repositories do too: their
signed index names the signer.
The origin note was only on the Add preview. An Obtainium export full of
APKPure apps went in without a word about them. Now the import summary
lists the apps that came from third-party stores. It says what their
first install decides, and while stores are off it says the apps wait
and offers the switch.

A checksum from a store was called the checksum of the publisher. It
belongs to the store. A match says the file arrived as the store has
it, not that the developer made it, and the line now says that.
"Share a link that opens it in Tern" put the address in the query of
tern.munzzyy.dev/add. The host and any CDN in front of it saw which app
was shared. The settings link went through Obtainium's redirect page,
and each recipient's browser sent that server the whole config.

Both links now carry what they share after '#'. A browser never sends
that part. The add page reads '#url=' first and still takes '?url=' from
older links. It hands '#app=' on to Tern as a tern://app/ link. Tern
reads its own add page links when they are pasted or shared in. The
Obtainium link is still there, named "Share as an Obtainium link". The
README badges use the hash too, and check-site.js covers all of it.
StoresLiveTest reads a real app from each store and developer channel
with the User-Agent the app sends. It follows each one to the first
bytes of its file and reads the package out of every APK. It fails when
a request carries another client's headers or goes to a host the source
does not name, so the host list in PRIVACY.md stays checked. Like the
other live tests it runs only with -Dtern.live=true.

JvmHttp now sends a request body and leaves a redirect alone when asked
to. The POST to Huawei and the vivo download needed both.
MainActivity is exported for the launcher. Any app could send it
UPDATE_ALL and get every pending update installed, with no prompt
under Shizuku or root. Any web page could open tern://refresh and make
Tern ask every source.

The shortcuts now go through an alias that is not exported. Only
intents that came through it run Update all, Add or a check. A refresh
link from outside asks the person first, once a minute at most.
check-apk.sh fails if the alias is ever exported.
The source check cannot see into a dependency. R8 keeps the names of
platform types in the dex. check-apk.sh now fails when it finds a
DownloadManager request or a web view there. It does the same for
HttpEngine, Cronet, OkHttp and DnsResolver.
ShizukuProvider called Sui.init at every start of Tern, before
App.onCreate. That reads ServiceManager through a hidden API and sends
a private call to the activity service, even for people who never
chose Shizuku. App.attachBaseContext now turns that off, and Tern calls
Sui.init itself the first time it asks about Shizuku as the installer.
With Shizuku, root or another app not ready, an install quietly went
to Android's installer and only Settings showed it. SECURITY-MODEL.md
says Tern says so. The verified entry of such an install now names the
installer that was chosen and says Android's was used.
"Keep Tern itself up to date" always added GitHub. A release there, with
the same key, then went over the F-Droid copy before F-Droid had built
and checked it, and the row said nothing about that. Now a copy an
F-Droid client installed follows the F-Droid entry. GitHub is still
offered below it, and says it skips the checks F-Droid makes.
SECURITY-MODEL.md named the widget and the shortcuts but not who else
could reach them. It now says both go to parts no other app can start,
and that a refresh link from outside asks first and never installs.
PRIVACY.md lists every host by what makes Tern ask it. The eight stores
get a table of their own with what each is told about the device. A new
PrivacyHostsTest fails when a store or an icon gets a host the page does
not name. Gradle now reruns it when PRIVACY.md changes.

The store listing names the hosts in groups and points to PRIVACY.md.
It no longer lists sources Tern dropped. The short description stays
true with the stores on. The README no longer says Tern talks to nobody
but your sources, and the language picker works on every version.
COMPARISON.md says which of the places Obtainium reads Tern leaves out,
and why. The security model loses the Russian root and gains the store
switch, the pins by package and the config test.
Rows inside a group had no focus slot, because an app filed under two
categories is drawn twice and two slots of one key would fight. So a
grouped list opened on Check all instead of its first app, and back from
an app's page lost the row.

Each place in the list is now keyed by its group as well as the app, so
back returns to the very row that was opened. With the first group
folded, focus lands on its header.
Select all also picked the apps inside folded groups. Remove after it
would then take apps nobody could see, and the dialog shows only a
count. An app shown under two categories counts once.
Machine-translated with the terms each language already uses in Tern,
then back-translated by a second model, whose corrections were taken
where they kept every placeholder, plural form and name.
A person can no longer set one, so the export tests kept a header
that cannot exist. The Galaxy Store option names no real model in
its comment either, which the identity check reads for.
The downloader keeps a file under its release and address together. Four
engine tests asked for it by the address alone. Three of them checked
that no file was kept and so could never fail. The fourth checks that
the file stays after a No in the system installer. It failed on every
device although the file was there.

With the key, a cancel that deletes the file fails that test.
A cut download is tried again by itself now, so the first fetch in this
test resumed and finished before the test could look for its partial
file. The other download tests already take away the second try; this
one does too.
Android answers an abandoned session as aborted, just as it answers a No
in its installer. Tern takes that as a cancel with no problem. The test
expected a failed install and failed on the phone and the television.

It now waits for that answer before it reopens the install. A session
Android never answered for still settles as not finished, and the test
checks that too.
The main button on the page of an app changes with what the app is
doing: Cancel while it installs, Open once it is in. The old button went
with the focus on it and left a remote on the Apps tab of the rail. Add
and install on the television ended there every time.

Now a button of that row that goes with the focus lands it on the row
again.
With large text in a narrow pane the title of a screen was squeezed
between the back button and the actions until it broke inside a word.
On the television at double size the list read "App" over "s".

The title now stays on the line of the buttons only while its longest
word fits there. Otherwise it goes on a line of its own under them.
The list beside the page of an app is at most 400 dp wide. At double
size on a television that is less than a phone at normal size. The
waiting banner and the first row filled it and pushed Update all out of
sight.

From one and a half times the text size two panes need more width in
step with the text. A television then shows the list alone, as a phone
does.
The remote tests expected Settings to open on the minus button of how
often. Only a device without touch has that button; a phone with keys
lands on the slider. A phone also has more rows, so Import apps lies
further than 40 presses down. The walks only ran on the television
before.
The chip test asked for focus once, on the first frame. On a phone that
does not take: the window is still in touch mode and not yet focused.
Tern's screens leave touch mode and try again for a few frames, and the
test now does the same.
On a phone the middle of a row is its link. A tap there asks whether to
open the link, and the test pressed Open and left Tern. It now uses the
action of the row itself, as TalkBack does.
The places a search looks in now stand between the field and the
results, and Wren was below the fold. The test clicked where it was not
shown and nothing happened.
Rows say when the release came out now. The test compared the whole
description and failed on that; it still checks that the version is
shown without its v.
The Add screen looked a pasted address up only after cutting it down to
its host, path and query. A vivo address such as
h5.appstore.vivo.com.cn/#/details?appId=40413 lost its app that way and
was read as a web page with no releases. The registry already takes an
address as typed for the stores that route after a '#'. Detection now
hands it the address as typed.

Found by running every source through the engine on a phone: the JVM
test of the vivo source passes the address straight to the source.
When a project publishes only pre-releases, the Add screen turns them on
for it and says so. The app that was added did not keep that when no
file fitted the device, as with an ARM-only APKCombo release on an x86
phone. Its row then said only pre-releases are published and asked for
the switch that was meant to be on.

The detection now says it turned them on, and the app is added with
them.
The live source tests run on the JVM. That cannot see what the runtime
of Android does differently. This test runs on a device and only with
-e live true. It detects, adds and checks one real app of each source
through the real engine. Every request must carry the User-Agent of
Tern. A refused site or a store while stores are off must make none.
The reason an app of a third-party store waits began with the word
Paused, right after the status that already says it. TalkBack read the
row as "Paused. Paused." and the page of the app showed the word twice.
The reason now starts with the stores being off, under a new key, and
the old key is gone.
Tern chooses its language itself on every Android version now, and the
Look page shows the style of its own colours wherever Android's
wallpaper colours are not in use. Two tests still expected the older
screens and failed on Android 10: one counted no Language row there,
and the other found two choices called Standard.
Shizuku 13.6 and Dhizuku 2.12 installed and updated without a prompt on
an Android 13 emulator. Root was not tried: the su of the emulator
images answers only adb. No emulator image can archive an app either.
The verifier and handoff tests that were marked as not run on a device
have now run on Android 10, 14 and 16. The emulator list in the README
now names the ones this version ran on.
A television at one and a half times the text size and more shows the
list and the page of an app one at a time now, and a title that does not
fit beside its buttons goes under them. The changelog and the README say
so.
A new source now gets one real app in LiveSourcesTest, run on a device,
since the JVM cannot catch a pattern that Android's ICU refuses. The
README says what that test covers and what it found. CONTRIBUTING no
longer says that every store is out of scope: stores go behind their
setting, while sites of modified apps and stores that need Tern to
pretend to be their app stay out.
A person with apps from Play or F-Droid saw the well known apps offered
again and read it as Tern wanting to install a second copy. Each row now
says when the app is in the list or on the phone, and when the copy on
the phone carries a key other than the developer's, so this address
could never update it.
Gemini translated them. The usual second model had no quota left, so
the four lines were read back by hand in each language, and the Polish
note said "creator" where it meant signer.
@munzzyy
munzzyy merged commit d7c4be6 into main Sep 30, 2026
2 checks passed
@munzzyy
munzzyy deleted the fix/integrate branch October 1, 2026 01:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant