Files
bvandeusen 81695fa0c8
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m28s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m36s
android: update the app from the server it syncs with (2727, M12 step 7)
Closes M12. The phone can now notice that its server has a newer build and
install it, instead of the operator copying an APK to a device by hand.

**A PackageInstaller session, not an install intent.** The obvious route —
ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned
against, and it is what produced the "bypassing Android security" warning on
Minstrel (Scribe note 2437). It also never tells the OS that this app is the
legitimate updater of its own package, and it returns nothing: a failed install
is indistinguishable from someone dismissing the dialog.

The session says who is doing what, and on Android 12+ declares no user action
required — which, with UPDATE_PACKAGES_WITHOUT_USER_ACTION, removes the
confirmation entirely on the UPDATE path. Only there: Android will not let an app
quietly put a NEW package on a device, which is right. It also only applies when
the new build carries the same signing key as the installed one, which is why
signing had to land first.

Two things from that research deliberately NOT done: `setRequestUpdateOwnership`
was chased and turned out to be a red herring, and REQUEST_INSTALL_PACKAGES is
not the differentiator either — Mihon declares it too. The mechanism was the
whole difference.

**The outcome comes back.** `commit` takes an IntentSender and the result lands
at `UpdateReceiver`, so a failure can be shown rather than guessed at, and
STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31
and still possible above it, since the OS is entitled to ask anyway. Someone
declining is reported as no error at all: calling a deliberate choice a failure
is how an app sounds broken when it is not.

**The network work stays in Rust.** Two FFI additions — `clientUpdate` and
`downloadClientUpdate` — because the device token lives in the core, and pulling
it into Kotlin to make an HTTP call would spread the one secret this app holds
across two languages for nothing. The core also owns the comparison, so the rule
"version CODE decides, never the name" lives in the layer that has to get it
right for every surface.

The download is streamed to disk, not buffered: 55 MiB in memory on a phone is
how an update gets killed halfway through. It lands in `update.apk.part` and is
renamed only once size and sha256 both match, so an interrupted download can
never be mistaken for a finished one. The digest is not a trust anchor — the
signature is, and Android checks it — but it catches a truncated transfer before
the installer is bothered with it. The advertised path is joined to the base URL
this device is LINKED to rather than followed as given, so a server cannot point
the download at a host nobody agreed to.

**Updates are linked-only, and it says so.** An unlinked install has no update
path, so it gets one sentence explaining where updates come from rather than a
Check button that silently finds nothing — the same lesson as the desktop's
unlink copy (issue 2110). And the "install unknown apps" grant is asked for
BEFORE downloading, so nobody spends 55 MiB to be told no.

Every Android API here was read out of `android-36/android.jar` with javap
first, and the two new FFI methods out of freshly generated bindings, rather
than recalled: `suspend fun clientUpdate(installedVersionCode: Long):
ClientUpdate?` and `downloadClientUpdate(destPath: String)`.

Also fixes `check-symbols.py`, which reported four false positives on
`UpdateOutcome.Result` — its object-member index collected functions and
properties but not nested TYPES, and a data class inside an object is an
ordinary member.
2026-08-21 08:44:08 -04:00

195 lines
7.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Flag capitalised identifiers that are neither imported nor declared locally.
This exists because ktlint and detekt are both structurally blind to it: they
parse Kotlin without resolving symbols, so a *missing import* is invisible to
them and both pass a file that cannot compile. The first sync-screen push failed
in CI on exactly that (`Unresolved reference 'Build'` — `android.os.Build` was
lost in a file split), after a clean local analyzer run.
Not a type checker and not trying to be. `compileDebugKotlin` in CI is the real
one; this is a cheap pre-push filter for the single mistake that survives every
other local gate. It errs toward false positives — anything it cannot account
for is reported rather than assumed fine.
It also checks MEMBERS of this package's own `object` declarations — `Foo.bar()`
where `Foo` is an object declared here. That case was added after moving a
function between two objects and forgetting to paste it into the second: the
call site read `Other.thing()`, resolved fine as far as the leading token, and
failed in CI (785ebdb).
Still NOT caught, so a clean run is not over-read: members of anything declared
outside this package, members reached through a variable rather than a type
name, and every question about types. Those are what `compileDebugKotlin` is for.
python3 android/tools/check-symbols.py [source-root]
Exits non-zero when something is unaccounted for.
"""
import collections
import os
import re
import sys
DEFAULT_ROOT = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "app", "src", "main", "java"
)
# Available without an import: kotlin.* and kotlin.collections.*, plus
# java.lang.* which Kotlin/JVM also imports by default.
IMPLICIT = set(
"""
String Int Long Short Byte Boolean Char Float Double Unit Any Nothing Number
UInt ULong UShort UByte Array List Set Map MutableList MutableSet MutableMap
Collection Iterable Iterator Sequence Pair Triple Comparable Comparator
Throwable Exception RuntimeException IllegalArgumentException IllegalStateException
Error Result Regex StringBuilder CharSequence Enum Annotation Function
Deprecated Suppress OptIn JvmStatic JvmField JvmName JvmOverloads Volatile
Synchronized Throws Target Retention Repeatable MustBeDocumented
System Math Object Class Thread Runnable Void Integer Character
StringBuffer
""".split()
)
# `R` is generated at build time and never imported from the app's own package.
GENERATED = {"R"}
DECL = re.compile(
r"^\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
r"(?:expect |actual |external |abstract |final |open |sealed |data |value |"
r"inline |enum |annotation |fun |companion |const |lateinit )*"
r"(?:class|interface|object|typealias|fun|val|var)\s+"
r"(?:<[^>]*>\s*)?([A-Za-z_]\w*)",
re.M,
)
def strip(src: str) -> str:
"""Blank out comments and string literals.
Order matters: raw strings before block comments, and line comments must NOT
use DOTALL — `//.*` with re.S eats from the first comment to end of file,
which silently empties the input and makes the whole check pass vacuously.
"""
src = re.sub(r'"""(?:.|\n)*?"""', '""', src)
src = re.sub(r"/\*(?:.|\n)*?\*/", " ", src)
src = re.sub(r"//[^\n]*", " ", src)
src = re.sub(r'"(?:\\.|[^"\\\n])*"', '""', src)
return src
def object_members(src: str) -> dict:
"""Map each `object Foo` declared here to the names declared directly in it.
Brace-counted rather than regex-matched: an object body contains nested
braces (lambdas, apply blocks, companions) and no regex closes correctly over
them. Only top-level members count — anything nested deeper is not reachable
as `Foo.member` anyway.
"""
members = {}
for match in re.finditer(r"^(?:internal |private )?object (\w+)\s*\{", src, re.M):
name = match.group(1)
depth = 0
body_start = match.end() - 1
for i in range(body_start, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
break
body = src[body_start + 1 : i]
own = set()
depth = 0
for line in body.splitlines():
if depth == 0:
# Nested TYPES count as members too: `Foo.Bar` where Bar is a
# data class inside object Foo is an ordinary reference, and
# leaving them out made the checker report four false positives
# the first time an object held one.
decl = re.match(
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*"
r"(?:fun|val|var|class|object|interface)\s+"
r"(?:<[^>]*>\s*)?(\w+)",
line,
)
if decl:
own.add(decl.group(1))
depth += line.count("{") - line.count("}")
members[name] = own
return members
def main() -> int:
root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_ROOT
files = [
os.path.join(d, n)
for d, _, names in os.walk(root)
for n in names
if n.endswith(".kt")
]
declared = collections.defaultdict(set)
objects = {}
parsed = {}
for path in files:
with open(path, encoding="utf-8") as fh:
raw = fh.read()
package = re.search(r"^package\s+([\w.]+)", raw, re.M).group(1)
src = strip(raw)
parsed[path] = (package, src, raw)
objects.update(object_members(src))
for match in DECL.finditer(src):
declared[package].add(match.group(1))
# Enum entries are declarations too; DECL only sees the class itself.
for match in re.finditer(r"enum class \w+[^{]*\{([^};]*)", src):
for entry in match.group(1).split(","):
name = entry.strip().split("(")[0].strip()
if re.fullmatch(r"[A-Z]\w*", name):
declared[package].add(name)
problems = 0
for path in sorted(files):
package, src, raw = parsed[path]
imported = set()
for match in re.finditer(r"^import\s+([\w.]+)(?:\s+as\s+(\w+))?", raw, re.M):
imported.add(match.group(2) or match.group(1).split(".")[-1])
# Type parameters are declared inline at their use site.
type_params = set()
for match in re.finditer(r"(?:fun|class|interface)\s*<([^>]*)>", src):
type_params |= set(
re.findall(r"\b([A-Z]\w*)\b(?=\s*(?::|,|$))", match.group(1))
)
known = imported | declared[package] | IMPLICIT | GENERATED | type_params
# Capitalised tokens NOT preceded by a dot: `Icons.Filled` resolves
# through `Icons`, so only the leading segment needs to be accounted for.
for match in re.finditer(r"(?<![\w.])@?([A-Z][A-Za-z0-9_]*)\b", src):
name = match.group(1)
if name in known:
continue
line = src[: match.start()].count("\n") + 1
print(f"{os.path.relpath(path, root)}:{line}: unresolved '{name}'")
problems += 1
# Members of objects declared in this package.
for match in re.finditer(r"(?<![\w.])([A-Z][A-Za-z0-9_]*)\.(\w+)", src):
owner, member = match.group(1), match.group(2)
if owner not in objects or member in objects[owner]:
continue
line = src[: match.start()].count("\n") + 1
print(
f"{os.path.relpath(path, root)}:{line}: "
f"'{owner}' has no member '{member}'"
)
problems += 1
print(f"\n{len(files)} files, {problems} unresolved")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())