Skip to content

Add AdMobile patches: ads, pro unlock, and sign-in on re-signed builds - #55

Open
andronedev wants to merge 29 commits into
mainfrom
claude/admob-app-patch-analysis-52fij2
Open

Add AdMobile patches: ads, pro unlock, and sign-in on re-signed builds#55
andronedev wants to merge 29 commits into
mainfrom
claude/admob-app-patch-analysis-52fij2

Conversation

@andronedev

Copy link
Copy Markdown
Owner

Description

Adds support for AdMobile (io.stark.admob) alongside the existing Transit patches.

Ads. Hide Ads returns early from AdNativeView.setNativeAd after releasing the ad, so nothing is ever drawn. Disable Ad Requests blanks the three ad unit string resources, so no request leaves the device. AdMobile 2.4.8 serves native ads only, in four placements that share one pipeline, so these two cover all of it. Both anchors survive R8: the view class is referenced from layout XML, setNativeAd is kept by the default ProGuard rule for set* on View subclasses, and resource names are never obfuscated.

Pro. Pro is one boolean field, written only by the verifyAppPurchase body, which checks a persisted purchase against the stored Play public key locally. Pro Unlock forces both writes to that field to true, including the branch taken when nothing was ever purchased. Pro Badge comes with it and relabels the profile screen's premium button, since the app's own indicator is a coloured ring around the avatar that reads as decoration.

Sign-in. A re-signed APK cannot complete Google Sign-In: Google validates the calling package against the SHA-1 the OAuth client was registered with and answers DEVELOPER_ERROR (10). microG does not help, since it reports the app's real certificate to the same endpoint. Injecting a refresh token alone does not work either, because the client secret never ships in the APK; the app downloads it from the developer's Firestore after a successful Firebase sign-in.

Custom AdMob Credentials replaces the whole flow with the user's own OAuth client. Everything past the authorization code is plain HTTPS that does not care how the APK is signed, so the patch redirects the app's reads of the client id, client secret, publisher id and tokens to an extension, and points the app's own sign-in button at a form that runs the consent flow in the browser with PKCE on a loopback redirect. Values live in the app's private preferences, so a patched build carries no secret and one build works for anybody. Until they are filled in, every hook falls through and the app behaves as it did before patching.

The extension also writes the account into the app's own Room database, because answering the one startup query is not enough: the home screen observes the account list through a different query.

Anchoring. The app has two stores of the same shape, one for secrets and one for settings. They are told apart by the key names they hold, which are string constants R8 leaves alone, and both reads are hooked. That is also what supplies the currency symbol, which otherwise stays unset in a patched build and makes every amount render as null 0.08. The obfuscated key class and the field holding the key name are read off the matched method's signature rather than written down.

Additional context

tools/apply-admobile.py performs the same edits on an apktool-decoded APK, for when the Morphe toolchain is unavailable. Its anchors are the obfuscated names of AdMobile 2.4.8, so it needs updating for later versions, whereas the patches find theirs through fingerprints. tools/check-admobile-api.py replays the API calls from a desktop, to tell a build problem from a Google project problem.

Known limits: the patch fabricates the single selected account, so the account switcher stays empty; the Firebase session is never established; and refresh tokens issued by a consent screen still in Testing expire after seven days.

Test results

  • Tested on both the minimum and maximum supported versions

Tested on AdMobile 2.4.8, the only version currently declared compatible, on a physical device: sign-in through the form, reports and per-app breakdowns rendering real AdMob data, correct currency symbol, sign-out, and no ads. The patched APK must be built from a universal APK; AdMobile ships as a split set and the base split alone installs as "app not compatible with this device".

  • Tested on experimental supported versions (Optional)

Generated by Claude Code

AdMobile (io.stark.admob) 2.4.8 monetises exclusively with AdMob native
ads across four placements (home, apps, app info, mediation). All of them
share one pipeline: the base fragment requests an ad through
AdLoader.Builder(context, getString(R.string.ad_*_native)) and the loaded
ad is bound by AdNativeView.setNativeAd(NativeAd), which is what makes the
otherwise GONE view visible.

Cut the pipeline at both ends, anchoring each patch on a name R8 cannot
rename so they survive app updates:

- Hide Ads returns early from AdNativeView.setNativeAd after releasing the
  ad. The class name is kept by the layout XML reference and setNativeAd
  by the default ProGuard rule for set* members of View subclasses. The
  method reserves 19 locals, so the parameter registers sit above v15 and
  the release goes through invoke-virtual/range.
- Disable Ad Requests blanks the ad unit id string resources. Ad unit ids
  are only null-checked for the context, so an empty id makes the request
  fail rather than return an ad, and resource names are never obfuscated.

Neither patch touches the purchase state, so pro features stay gated
exactly as in the stock app.
Pro is a single boolean field on AdMobile's DataStore wrapper, written in
exactly one place: the verifyAppPurchase suspend body, which RSA-verifies
the purchase json and signature persisted after the last Play purchase
against the stored Play public key. Everything else reads it — the billing
client mirrors it into a MutableLiveData at startup and after every
processed purchase list, and the base fragment fans it out to the report
charts (real data instead of the demo series), the ad unit and ad source
breakdowns (full list instead of a single teaser row), multi account
support, the profile accents and the branch that skips loading an ad.

Force both writes to that flag to true: the one after a failed
verification and the one taken when no purchase is persisted at all.
Overriding the verification result alone would miss the second, which is
the path a device that never bought anything takes. The method is located
by the log prefix it emits, distinct from the one the billing client uses
for a fresh purchase list, and strings survive obfuscation.

Also move Constants up to the admobile package now that it is shared
between the ads and misc patches.
A re-signed AdMobile cannot use Google Sign-In: Google checks the calling
package against the SHA-1 its OAuth Android client was registered with and
answers DEVELOPER_ERROR. But Google Sign-In only produces the initial
authorization code. Everything after it is plain HTTPS that does not care
how the APK is signed: the app posts client_id, client_secret and
refresh_token to oauth2.googleapis.com/token itself, then reads reports
from admob.googleapis.com. Supplying those three values removes the GMS
step entirely, so no root, mount install or microG is needed.

The client secret is deliberately absent from the APK — the app downloads
it from the developer's Firestore after a successful Firebase sign-in,
along with the Play public key. Injecting only a refresh token therefore
cannot work, so the patches take a full OAuth client of the user's own.

Five edits, four of them anchored on names R8 cannot rename:

- the DataStore read, the single decrypting lookup every secret passes
  through, answers the client secret, publisher id and any
  token_refresh_<id> key. It is a suspend function, so returning a value
  rather than COROUTINE_SUSPENDED is its ordinary non-suspending path.
- the pre-DataStore SharedPreferences read answers the refresh token too,
  since the OkHttp authenticators still look there first.
- checkUser() sends the app to the login screen when the user DAO returns
  no selected account, so that query hands back a fabricated one. The DAO
  carries no strings and is located through the call checkUser() makes
  just before logging the result.
- the account is built by a factory added to the entity, which needs ten
  registers, more than the DAO method reserves.
- client_id comes from a string resource, so it is a resource patch.

tools/apply-admobile-credentials.py performs the same edits on an
apktool-decoded APK for use without the Morphe toolchain. The smali
assembles and the resulting APK builds; the flow has not been exercised
against a live account.
Replace the build-time options with a form the patch adds as a second
launcher entry. The values live in the app's private preferences, so
nothing is compiled into the APK: a patched build carries no secret and
one build works for anybody. Every hook falls through while the form is
empty, so an unconfigured build behaves exactly as it did before patching.

The form is built in code rather than from a layout, so the patch adds no
resources and has no ids to keep in step with the host app. It is reached
through its own launcher entry, which leaves the app's navigation graph
untouched.

Hooks, all anchored on names R8 cannot rename:

- Application.onCreate hands the extension a context before anything reads
  a credential; its class is named in the manifest so R8 keeps it.
- the DataStore read, the single decrypting lookup every secret passes
  through, is answered for the client secret, the publisher id and any
  token_refresh_<id> key.
- the pre-DataStore SharedPreferences read is answered for the refresh
  token, which the OkHttp authenticators try first.
- the client id is substituted on the app store constructor argument,
  since it is read once there and travels to both token requests as a
  field. The constructor reserves no locals, so the parameter register is
  reused in place.
- the user DAO hands back a fabricated account once credentials exist,
  which is what stops checkUser() from routing to the login screen.

The extension compiles against the Android API, the edits assemble, and
the built APK carries the extension classes. The OAuth flow has not been
exercised against a live account.

tools/apply-admobile-screen.py performs the same edits without the Morphe
toolchain; the earlier fixed-value script stays for personal builds.
Drop the launcher entry. While nothing is stored the launch screen's own
sign in button opens the form instead of the Google flow, so setup is one
tap from the screen a fresh install already lands on and the app's
navigation graph is untouched. Saving restarts the app, which is all the
account needs to be picked up. Once credentials exist the account is
served locally and the button is never reached again.

Both the sign-in client and its click handler are obfuscated, but the
launch fragment is named in the navigation graph, so the fingerprint is
the one method mentioning it that builds an Intent without arguments.

The form now separates required from optional fields, explains what the
credentials are for, and names what is still missing instead of refusing
silently. The activity is declared but not exported, since nothing
outside the app starts it.

Also add the empty extension manifest the Android plugin requires, which
was the last thing keeping the module from building.
apktool interleaves .line directives and blank lines between a call and
its move-result, so the pattern anchored on adjacent lines never matched.

Check every anchor before writing anything as well. The previous run
failed halfway through, after four edits had already been written, and
re-running then applied them a second time and produced duplicate labels
that only a fresh decode could clear.
Drop the separate launcher entry. While nothing is stored, the launch
screen's own sign in button opens the form instead of the Google flow, so
setup is one tap from the screen a fresh install already lands on and the
app's navigation graph stays untouched. Saving restarts the app, which is
all it takes for the fabricated account to be picked up.

Both the sign-in client and the click handler are obfuscated, but the
launch fragment is named in the navigation graph, so the single
no-argument call returning an Intent inside a method mentioning it is the
one to redirect. The activity is now declared unexported, since it is only
ever reached from inside the app.

The form gained required and optional sections, per-field hints, and a
save that names what is still missing rather than refusing silently.

Add tools/get-admobile-token.py, which runs the consent flow against a
loopback redirect and prints every value the form asks for. It reads the
publisher id back from the AdMob API, so that one no longer has to be
looked up in the AdMob console.

Also add the empty extension manifest the Android plugin requires, which
was what stopped the extension module from building.

Verified with the real toolchain: ./gradlew build is green across 98
tasks, producing patches-2.3.0.mpp and extensions/admobile.mpe.
Signing in becomes one tap with nothing to paste: the browser opens on the
consent screen, and the refresh token, publisher id, currency and time zone
all come back from Google. The manual fields stay, folded away, for builds
without a bundled client or a token obtained elsewhere.

PKCE with a loopback redirect, served by a short-lived ServerSocket in the
app. Loopback rather than a custom scheme keeps the redirect out of the
manifest, so the patch does not need the client id at build time to declare
an intent filter for it. The OAuth client is of type Desktop, the only kind
bound to neither package name nor signing certificate, which is what lets a
single build be handed to anybody.

The client is set through the clientId and clientSecret patch options,
written into two extension getters with returnEarly. Set them once and users
of that build enter nothing at all; leave them empty and the app asks for a
client instead.

Note that while the consent screen is in Testing, Google caps it at 100
accounts and expires refresh tokens after seven days, so the app will ask to
sign in again weekly until the project is verified.

Also ignore .kotlin/, which the compiler writes into the working tree.

Verified with ./gradlew build: green, producing patches-2.3.0.mpp and
extensions/admobile.mpe.
The patch now puts AppTheme on the activity, the app's own
Theme.Material3.DayNight.NoActionBar. Colours are resolved from the theme's
Material colour roles at runtime, looked up by attribute name because the
extension compiles on its own and has no R class for them. The form follows
the app in light and dark and picks up the dynamic palette on Android 12 and
above, without shipping a colour of its own. Controls are drawn as Material 3
filled fields and buttons rather than platform ones.

Two failures the first device run exposed:

- The result of a sign in could be lost. The browser holds the foreground
  while the flow runs, so the system is free to tear the form down behind it
  and a toast on a dead activity goes nowhere. The outcome is now recorded
  before the callback and read back in onResume, and the app is pulled to the
  front as soon as the redirect lands, so the user does not have to find
  their way back by hand.
- The title sat under the status bar, which the activity draws beneath since
  the app's theme is edge to edge. It now carries that inset.

Also make failures diagnosable rather than generic: the token endpoint's own
error_description is surfaced, the redirect line is logged, and query
parameters are matched key by key instead of by searching for the name, so a
parameter merely ending in the one being looked for cannot be mistaken for it.
A device run failed with "Unable to resolve host oauth2.googleapis.com".
The consent had succeeded and the redirect had been captured, so the only
thing left was the exchange — which ran while the browser still held the
screen and the app sat in the background. Several vendor builds cut
background apps off the network, and that surfaces as the token endpoint
failing to resolve rather than as anything OAuth shaped.

Split the flow in two. Capturing the redirect stays on the background
thread, but the code is now persisted and the exchange waits for the form
to resume, which is also when the app is pulled back to the front. Network
calls retry a few times as well, since connectivity can lag a moment behind
returning to the foreground.

The pending code is kept if the exchange fails: Google leaves it usable for
a few minutes, so simply coming back to the screen with a working
connection is enough to finish, without going through the consent again.
A device run signed in and reached the dashboard, but every report came
back empty. The log named the reason: "fetchApps: ... isTokensMissing:true".

That flag is the app store's own check, and it reads two names out of the
pre-DataStore storage: user_token_access and user_token_refresh. Only the
second was being answered, so the first came back blank and the app treated
the session as absent — it never issued a request at all, whatever else was
in place.

The access token from the exchange is now kept and served under both the
legacy name and the token_access_<id> DataStore key.

An access token lasts an hour, after which the app refreshes it for itself
and persists the new one, so both writers are mirrored as well: whatever the
app stores becomes the newer truth. Without that the reads would keep
answering with an expired token and every request would fail an hour in.
Iterating on this has meant shipping a build and waiting to hear what it did,
because an empty dashboard says nothing about its cause: the app stops at the
first missing piece and logs almost nothing on the way.

Two instruments replace the guessing.

In the app, a connection check reports what the hooks actually hold — client,
refresh token, access token, publisher id — then refreshes the token and lists
the account's apps, showing what Google answered. That separates a credential
problem from a plumbing one from inside the phone, where the failure happens.

Alongside it, tools/check-admobile-api.py replays the same chain from a
desktop against a file of credentials: refresh, list accounts, list apps,
generate a network report. Every step printing 200 means the credentials are
sound and any remaining emptiness belongs to the app.

Note for the record that a hardware accelerated Android emulator is not
possible in this environment: there is no /dev/kvm and the CPU exposes no
virtualisation flags, so the device stays the only place the patched app can
actually run.
The form asked for seven values at once, four of which the sign in fills in
by itself. It now reads as the two steps it really is: name the Google API
client, then connect an account with it. The client id and secret are
properties of the build rather than of whoever is signed in, so they survive
disconnecting and come back prefilled.

Disconnecting was impossible. The app's own sign out forgets an account held
in its database, but the account it is shown comes from the extension, so it
reappeared immediately. There is now a disconnect that clears the session and
keeps the client.

Reaching the form once signed in was impossible too, since the launch screen
is skipped. Every call site building the sign-in intent is redirected rather
than just the launch screen's: the add account action builds the same one,
which makes it the way back in. The call sites are found from the reference
resolved at the launch screen, so a new one would be covered without further
work.
Step 1 asked for a client id and a secret without saying where either is
found, which is the one part of the setup that cannot be automated away. It
now folds out five numbered steps, from creating the project to copying the
two values, and a button that opens the Google Cloud credentials page.

The numbering is a badge per row rather than text, so the sequence stays
readable on a narrow screen, and it is collapsed by default so it does not
crowd the form for anyone who has already been through it.

The steps also say to leave the consent screen in Testing, and name what that
costs: Google expires the connection every seven days. That is the sort of
thing better learned before the setup than a week after it.
Signing out did nothing. The app forgets the account held in its database,
but the patched app's account is served from the extension instead, so the
next check handed it straight back. AccountManager.signOut now clears the
extension's session too — it is found by the log line it emits, a string R8
leaves alone.

Restarting after a disconnect now ends the process as well. Clearing the task
was not enough: the account, the pro flag and the view models all live in
memory, so the old session was carried into the new screen. Android brings
the launch activity back in a fresh process, which is what makes the restart
real.

Also fix the standalone script's preflight check, which mapped anchor names
to files by exception and so looked for the new one in the wrong file. The
mapping is a table now, which is what it should have been from the start.
…or it

The dashboard stayed empty even with every credential in place. A device log
shows the startup check passing — isTokensMissing false, checkUser returning
the account — and then nothing at all: no request, no error.

Answering the query for the selected account got past that check, but it was
the only query answered. The rest of the app reads the same users table
through other queries, the account list the home screen observes among them,
and those still saw an empty table, so no report was ever asked for.

The account is now inserted into the app's own Room database instead, which
makes every query see it. It is written from Application.onCreate before Room
opens the file, and again as soon as a sign in completes; signing out deletes
it. The query hook stays as well, since it covers the window before the first
seed lands.

The schema is the one the app's own DAO declares, columns and order included.
Every amount was printed as "null 0.08", on the cards and on the chart axis
alike. The formatter is handed a symbol once, on startup, from a setting the
app writes when an account is selected through its own sign in. The patched
app never takes that path, so the setting stayed absent — and the formatter
appends a space to whatever it is given, which turned the missing value into
the word null. The symbol is now derived from the account's currency, which
the extension already knows, and only when nothing was stored.

The profile screen also says nothing about pro. The app's own indicator is a
premium coloured ring around the avatar, which reads as decoration; the button
that used to sell the subscription is on the same screen, already carries the
premium icon, and is the one thing there whose label states a state. It now
reads "Premium active". The label is set in the layout rather than at runtime
because a patched app is always pro: there is no second state to switch to.

Also fix the sign-in redirect, which looked up a mutable method through an API
this fork does not have, and so had never compiled.
The setup steps named only the AdMob API, so a project built by following them
serves the reports and then fails on the profile screen: the payments card is
read from adsense.googleapis.com, and Google answers that the API has never
been used in the project. The scope was already requested, so nothing but the
console switch was missing.

Both the in-app guide and the readme now name it, and say what it is for so
that leaving it off is a choice rather than a surprise.
Four review passes over the AdMobile work. The findings that mattered:

The app has two stores of the same shape, one for the secrets and one for the
settings, and the fingerprints told them apart by counting fields. Which one
they matched was iteration order. Both are now identified by the key names they
hold, which are string constants R8 leaves alone, and both reads are hooked,
which is what the currency symbol needed all along: it lives in the settings
store, and answering that read fixes it at the source. The setter fingerprint
that stood in for it is gone, and it was the worst of the set, since fingerprint
strings match by containment and it asked for "$" and " ".

The obfuscated key class and the field holding the key name are read off the
matched method's own signature now instead of being written down, and the
parameter types that were spelled out as R8 output are matched as "any object".

Pro Unlock forced every boolean write after its anchor; it now forces only the
writes to the field the first one names.

The patches were re-implementing the fork's own helpers: findMutableMethodOf,
findInstructionIndicesReversed, indexOfFirstInstructionOrThrow, navigate, and
the ResourceUtils DOM helpers. Adding a string through addResource also restores
the quote escaping the raw DOM path skipped. Both resource patches are now
idempotent, and the premium button is located by id rather than by widget class.

Two tools are removed. apply-admobile-credentials.py answered the refresh token
but not the access token and never seeded the account, so a build made with it
today would show an empty dashboard; get-admobile-token.py existed only to feed
it, and told the user to paste values into fields the form no longer has. The
remaining script is one table of edits instead of a table, a parallel table and
ten wrappers, and it reads and writes each file once, which is what makes its
"check everything before writing anything" guarantee true.

In the extension: seeding the account no longer opens the database on every
launch, only when the row would change; the store hooks match the key name
before reading anything back, since they run on every read the app makes; the
email column was always empty and is gone; and signInIntentOrOriginal never
returned the original, so it is signInIntent() and the call sites stop passing
one. Sign out commits once rather than eight times, deletes its own row rather
than the table, and the socket, database and server close through one helper.
The script only ever carried the credential edits. The ad and pro edits had no
written form at all, which is how a build went out with them missing: rebuilding
from a different base silently dropped them, and nothing in the repo said they
were meant to be there.

All six patches are in it now, so the script and the patches describe the same
build, and it is named for what it does rather than for the screen it started as.

The docstring also says to decode a universal APK. AdMobile ships as a split
set, and the base split alone carries a splits0.xml and none of the density
resources, which Android reports as "app not compatible with this device".
The how-it-works prose belonged in the patch sources, where it already is. What
is left is the setup, the limits and the two scripts.
A merged APK carrying only lib/armeabi-v7a looks 32-bit only, which a
64-bit-only device refuses. The library is an optional DataStore component and
dropping it costs nothing.
The exchange stored the tokens, then read the account back, then dropped the
authorisation code. A failure between the two left a spent code in place, so
the next attempt replayed it and Google answered invalid_grant, which the form
showed as "Token exchange refused: Bad Request" on every retry. The tokens
were already good; only the publisher id was missing, and no amount of retrying
could add it.

The code is now dropped as soon as Google answers, and the account lookup is a
step of its own that can be retried on the tokens already held. Reopening the
screen finishes the job, with no second trip through the consent screen.
Returning from the browser looped on "Finishing...". The form ran any
unfinished sign in work on every resume, and the work it was given included
recovering a missing account, whose condition stays true until it succeeds. A
success then restarted the app, which resumed the form, which started again.

Only a captured authorisation code runs on resume now. That code is consumed by
the attempt whatever the attempt concludes, so it cannot repeat. Recovering the
account is on the button instead, which relabels itself to say so, and one flow
at a time is enforced whichever way it is entered.

Adds the tests that would have caught it: 67 cases over the extension's storage,
its answers to the app's reads, the seeding guard and this state machine. They
run on the JVM against stubs, so no device is involved.
Signing in restarts the app by exiting the process. Preference writes went
through apply, which only queues them, and a queued write does not survive
exit(0). The publisher id is written moments before that restart, so it was
dropped every single time: the tokens survived because they were written
earlier, the account did not, and the screen came back offering to finish a
connection it could never finish. Retrying reproduced it exactly.

Writes are synchronous now. They are a handful of short values written a handful
of times, so the cost is not worth measuring against losing them.

The tests missed this because the harness treated storage as reliable. It now
models the two-stage write and the process death, and a lifecycle suite runs a
sign in across the restart it ends with. On the previous code those cases fail,
publisher id included.
The app crashed with a NullPointerException reading the key name in the settings
store. A body injected at the top of a suspend function runs on every resumption
of it, not only on entry, and Kotlin passes null for the value parameters when
it resumes. Reading the key there was therefore a crash on the first read that
had to wait for the disk. The hooks check the key first and fall through when it
is absent, which is what a resumption is.

The same was latent in the app store's read and write hooks, for any key the
extension does not answer.

Adds tools/verify-admobile.py, which asserts this rule and the rest of the patch
against a decoded tree, so a build is checked before it reaches a device. On the
tree that crashed it reports the exact method.
Verifying a patched APK meant reasoning about it, because the sandbox has no
/dev/kvm and the emulator falls back to interpreting the guest. It does run at
that speed; what fails is everything Android times out on, and each of those
failures reads as a problem with the app rather than with the machine.

The skill writes down which ones are which. The image without Play services,
because the watchdog kills system_server under the load of the ones with it.
The wait after boot_completed, because the package service arrives later and
installing into that gap is what corrupts it. The install that must not stream.
The certificate that must go in the system store under the old subject hash,
for a proxy that terminates TLS. And that pgrep -f matches the command line of
the shell running it, which is how an emulator you just killed reports itself
alive.

Its longest section is about not trusting the screen. A slow emulator renders
half-drawn frames and cannot show what reached the disk, whereas the app's own
preferences, database and log answer the question and can be quoted back.

Three scripts carry the parts worth not re-deriving: boot, install, trust.
…that makes redundant

Home screen widgets came back empty. Pro is one boolean field, and the patch
forced the write to it inside verifyAppPurchase — a body that only runs once the
billing client has processed a purchase list. A widget worker can start the
process on its own, with no billing client and no purchase list, so it read the
flag before anything had set it and rendered as if nothing had ever been bought.

Every read is answered true instead. There is no longer an order of events in
which a gate sees false, whichever entry point woke the process, and the patch
needs no knowledge of what each gate goes on to do. The read is replaced rather
than preceded, so the object it would have dereferenced is left alone.

The LiveData the screens observe is seeded as well. It is created empty, so
until billing fills it "is the user pro" answers no, which is both the window
the ad loader lives in and the reason a cold start showed nothing premium.

Hide Ads and Disable Ad Requests go: the ad loader sits behind that same flag,
so no ad is requested or drawn, and blanking the ad unit ids was belt on top of
braces. Pro Badge goes too. It relabelled the profile button, which was a
cosmetic claim rather than the thing itself; with the flag true everywhere the
app shows what it shows for someone who actually paid, and that is the right
answer.

Custom AdMob Credentials becomes Serverless Sign-In, which says what it now
does. The app no longer fetches its client secret from the developer's
Firestore, so nothing leaves the device but calls to Google's own APIs signed
with credentials only the user holds. Not offline, and the readme says so: the
reports still live at admob.googleapis.com.

The verifier now fails on any surviving read of the flag, and names the file.
On the tree that shipped the empty widgets it names lh/b.smali.
The widgets render now that the pro flag is answered everywhere, but they show
zero. They fetch their own report, and the header they send is built from the
stored access token, which Google issues for one hour.

Nothing was keeping that token current outside the app. The app refreshes its
own whenever a request comes back unauthorised, so while somebody is using it
the stored value stays good; a widget updates on its own schedule, reads
whatever was left behind, and sends it expired. The request returns no data,
and no data reads on screen as zero earnings.

The extension now judges the age of what it holds before handing it out, and
trades the refresh token for a new one when it has aged past the hour. Every
caller gets a usable token, whichever process it runs in and however long ago
the app was last opened. The refresh needs a network call, so a caller on the
main thread still gets what is stored and lets the app's own authenticator do
the work from its background thread.

Obtaining a token now records when, in one place, so the age is knowable at all.
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