Releases: neulketing/openthumb
Release list
OpenThumb 1.3.0
Added
- The sandbox image can be fetched at first run instead of bundled, which is
what F-Droid requires: it builds every app from source and will not ship a
tarball of several hundred precompiled executables it cannot rebuild. The
projects that do get in — Termux, UserLAnd — all download theirs. Which path a
build uses is decided by what the APK holds: the asset is used when present and
the image is downloaded when absent, so nothing in gradle has to know and
existing builds behave exactly as before. - The download is verified against a SHA-256 compiled into the app, and a
mismatch deletes the file and fails. This is not belt-and-braces: without it,
whoever can answer for that URL picks which native binaries land in the
sandbox. An empty digest disables the download path entirely rather than
accepting anything, so a build that forgot to set one fails loudly. - Resumable via HTTP Range, because 14 MB on a phone is long enough for Doze or
a walk out of Wi-Fi range to interrupt, and restarting from zero turns a slow
install into one that never finishes. A server that ignores the range and
sends the whole file discards the partial bytes rather than splicing two
copies together. Downloadingis a separate install state fromExtracting. One is waiting on
a network and can be retried or resumed, the other is waiting on the device; a
single bar for both tells the user nothing about which is stuck.scripts/publish-rootfs.shbuilds the image, prints its digest, and refuses
to publish while that digest disagrees with the constant in the app — an app
shipping the wrong digest rejects its own image.
OpenThumb 1.2.0
Added
- Sync no longer requires a Cloudflare account.
tools/sync-server/is the
same backend as a single Python file on a machine you already own: SQLite
where the worker uses D1, a directory where it uses R2, and no dependencies at
all. Same three endpoints, same bearer token, same 64 KB inline limit, same
last-write-wins rule, so the app cannot tell which one it is talking to and
switching means changing a URL. 39 checks written from the worker's own source
run in CI, because the thing worth testing is not that the server works but
that the two cannot drift apart. - The server refuses to bind a non-loopback address without TLS rather than
quietly putting the bearer token on the network in cleartext; the README gives
three ways to reach it from a phone, in the order most people should try them.
Blob filenames are hashed fromkindandid, so an id — which is user data
arriving from the network — never becomes a filesystem path.
OpenThumb 1.1.1
Fixed
- The app could not start when an older build was still installed. The
native-offload endpoint bound the abstract socketnative-offload, and the
Linux abstract namespace is global to the device rather than per-app — so
whichever OpenThumb started first claimed it and the other died in
Application.onCreatebefore drawing anything. Changing the application ID
puts every existing user in exactly that state, because the new id installs
beside the old rather than over it. The socket is now named after the
application id. Verified on a Note8 with 1.0.x and this build running at the
same time: both processes alive, no crash, bound as
com.fug.openthumb.native-offload.
Added
- Nothing the agent writes reaches another person without someone saying
yes. The trigger engine had five gates — quiet hours, an active window, a
per-rule cooldown, a concurrency cap, and a check that the notification can be
replied to at all — and every one of them limits how often a reply is sent.
None looked at what it says. So an agent that misread a message answered a
real person, at speed, and the first anyone heard of it was the reply already
in the thread. Replies now stop at a draft until the person whose name is on
them decides. - The decision arrives where you already are. The draft comes as a
notification with Send and Discard on it and an inline edit box, so correcting
a draft and approving it is one gesture rather than two screens. It shows the
reply in full rather than a summary — reading what is about to be sent is the
entire point — and it cannot be swiped away, because dismissing a question is
not an answer. - Three settings, written as consequences. Ask every time is the default,
because a gate that ships off is not a gate. Ask except for chosen apps
sends automatically only where you said to. Never ask is honest about when
it is reasonable: when your rules answer nobody but you. A rule can also carry
requireApproval, which only ever tightens — a rule marked that way waits even
in Never ask, because whoever marked it knew something the global setting
does not. - A record of what it tried to send, not only what it sent. Every draft and
every decision — sent, edited, discarded, expired — appends to
outbound-ledger.jsonland is readable in the app. A log holding only
successes cannot answer the question people actually have. - The decision itself is a pure function with no Android in it, and nine checks
cover the cases where being wrong is expensive: an unreadable stored setting
falls back to asking rather than sending,com.allowed.evildoes not inherit
com.allowed's permission, a rule's own flag holds a reply under every mode
including Never ask, and a queued draft keeps the notification key without
which an approved reply can never find its conversation again. Verified to
have detection power by removing the tightening rule — exactly that check
fails. - Ask except for chosen apps can now choose apps. The mode was selectable
with no way to put anything on the list, which made it behave exactly like
Ask every time — a setting that silently does nothing. Picking it now
reveals the installed apps with checkboxes, and only apps with a launcher
entry are offered, since a rule can fire on a bank alert but there is nowhere
to send a reply. minis://settings/triggersopens the notification-trigger screen, and the
approval notification links there. The screen had no deep link at all, so
tapping a draft did nothing and the only way to see how many were waiting was
to find the app on the home screen.- The approval settings scroll. On a Note8 the dialog was taller than the
screen: Never ask was cut mid-sentence, and the expiry setting and the
pending list below it could not be reached by any gesture. - Drafts expire after 30 minutes by default. An answer to a message from hours
ago fits the conversation worse than no answer at all.
Alpine rootfs 3.21.3 (aarch64, python3)
Sandbox image fetched at first run by builds that do not bundle it.
OpenThumb 1.1.0
Use 1.1.1 instead. This build binds the abstract socket
native-offload, which is device-global rather than per-app, so if an
earlier OpenThumb is still installed — which the note below tells you to
expect — whichever starts first claims the name and the other dies in
Application.onCreatebefore drawing anything. Fixed in 1.1.1 by naming
the socket after the application id. On a device with no earlier install
this build runs, but there is no reason to pick it over 1.1.1.
The application ID changes to com.fug.openthumb, so 1.1.0 installs beside
1.0.x rather than over it. Android treats a different application ID as a
different app entirely. Uninstall the old one after checking anything you want
to keep — settings and provider credentials do not carry across. Doing this now
is deliberate: the ID is fixed forever once the app is on Google Play, and the
product is published under FUG. The app is still called OpenThumb.
Added
openthumb-fetchin the sandbox — fetch a page and be told whether what came
back is actually the page. A WAF challenge, a consent wall and a bot
interstitial all arrive as HTTP 200 with an HTML body, and an agent that
trusts the status code summarises the interstitial as if it were the article.
Seven checks decide: status semantics, structural WAF markers, caller-supplied
stub sizes, JSON awareness, CSS selectors as positive proof, soft markers with
the Akamai sensor cookie, and content density.- A failed fetch never returns a bare failure. It names
untried_routes— the
escalation steps the fetcher structurally could not take — so "the site blocks
us" cannot be reported while the phone's own WebView, which holds the real TLS
stack and cookie jar, has not been tried.429is classified as transient
rather than a wall, which is the single most common cause of a premature
give-up. An empty list is the permission to fail honestly. - The ladder is inverted from the upstream desktop tool: here the browser is the
first escalation and cheap HTTP is the probe under it, because on a phone the
WebView is the real client. - The fetcher returns readable text, not markup. A measured Threads profile
is 861KB of HTML carrying 1.6KB of words — 516x. Returning the raw body would
spend an on-device model's whole context on one page, and truncating instead
drops whatever was past the cut, usually the article. Three sources compete
and the most complete wins: schema.orgarticleBody(which carries the text
even when the visible DOM is a JavaScript shell), the<article>/<main>
block with navigation and footers removed, or all visible text.--rawstill
returns the HTML for the cases that need it. - A fetch interrupted by Doze resumes instead of restarting. Android
suspends background work, and a fetch that walks several URL variants is long
enough to be killed partway — with no record of what was already tried, the
next run starts over and dies at the same place forever. Each attempt is now
appended to a journal as it finishes, and a resumed run skips what the journal
covers. Measured: 2.68s for a first pass, 0.14s to resume it. The journal
expires after ten minutes so a lifted block is retried, and--forgetstarts
clean. - Cookies are kept per host, so clearing a challenge once pays off. Hand the
WebView's cookies in with--cookiesafterbrowser_usegets past a
challenge and later fetches to that host start already solved. Storage is
keyed by host and stores the host inside the file as well, so a cookie can
never be attached to a request for a different site; the files are 0600
because session cookies are credentials. - It degrades honestly when it cannot start. The Alpine rootfs ships 15
packages and python3 is not one of them, and on a Galaxy Note8apkcannot
install it — network is fine (busybox wget fetches the same mirror over
HTTPS), but apk's own fetch fails with IO ERROR over http and https alike and
with both IPv6 and IPv4 nameservers. The wrapper triesapk add, and when
that fails prints a JSON object saying so and escalating tobrowser_use,
stating outright that this is a runtime problem on the phone rather than the
site refusing the request. Verified on the device: valid JSON, exit 2. The
same limitation applies to the inheritedminis-mcp-cli, which installs
itself the same way. - python3 is baked into the rootfs at build time, since it cannot be added
later.scripts/rootfs_add_packages.pyresolves an Alpine package's full
dependency closure and unpacks it into the image using nothing but curl and
the standard library — not apk, which is a Linux binary a macOS build host
cannot run, and not Docker, which would make the shipped image depend on
whether a daemon happened to be running. Measured: 3.7MB to 14.6MB for
python3 3.12.13 and its 17 dependencies, taking the APK from 33MB to about
44MB. Verified on a Note8: python3 runs, all 69 checks pass on the phone, and
openthumb-fetch https://example.comreturns the page with the text
extracted. Details indocs/sandbox-python.md. - Ported from insane-search (MIT, github.com/fivetaku/insane-search). Pure
standard library — CSS selectors are matched withhtml.parserrather than
BeautifulSoup so nothing has to be installed on the phone.curl_cffiis used
for TLS impersonation when importable; its absence costs capability, never
correctness.scripts/test-fetch.shruns 69 offline checks, wired into CI
ahead of the build so a broken verdict fails in seconds rather than after the
NDK compile.
OpenThumb 1.0.6
Installing over 1.0.5 or earlier will fail — uninstall first. Every release
up to 1.0.5 was signed with an Android debug key, because the signing config
falls back to one whenever the release keystore is absent. 1.0.6 is the first
build signed with OpenThumb's own key, and Android refuses to replace an app
with a build carrying a different certificate. Your settings and provider
credentials do not survive the uninstall; export or re-enter them. This is a
one-time break — 1.0.7 onward upgrade normally.
Added
scripts/bump-version.sh— movesversionName,versionCode, the CHANGELOG
heading, the F-Droid changelog and the version on the landing page together.
They were four hand edits before, and fastlane still carried a changelog for
versionCode 21 while the app shipped 26.scripts/test-bump-version.sh
checks that all of them move..github/workflows/release.yml— a tag push builds the signed APK, publishes
the release with the notes read out of this file, and mails them. It checks
that the tag agrees with theversionNameit was cut from, and the tag is
annotated becausegit push --follow-tagsskips lightweight ones — a
lightweight tag stays local and the release never fires.
release-apk.ymlandrelease-announce.ymlare folded into it: both hung off
release: published, which a release created withGITHUB_TOKENdoes not
raise, so automating release creation would have silently stopped both.- The landing page links the changelog, and shows which version is current.
Fixed
- Release APK could be published signed with a throwaway key. Without the
keystore secretsbuild.gradle.ktsfalls back to the debug config, and CI
runners generate a fresh debug key per run — so a published build could not
be upgraded onto by anyone holding an earlier one. The workflow now signs
from secrets when they exist and fails rather than attaching a debug-signed
APK.
OpenThumb 1.0.5 — 이미 있던 기능을 말하기
코드가 아니라 문서 패치. 포크가 이미 하고 있었지만 어디에도 적혀 있지 않던 것 3가지.
Changed
- README가 개인 사용자부터 말합니다. 기존 헤드라인은 "One host. Many phones" — 폰 한 대 가진 사람이 남의 사용 사례를 읽는 구조였습니다. 서랍 속 폰 되살리기와 "메시지가 온 그 대화창에 답장"을 먼저 서술하고 함대는 그다음으로.
- 계정 로그인이 문서화됐습니다. 앱에는 Claude·OpenAI·Gemini·xAI·Kimi·OpenRouter·Antigravity OAuth가 이미 구현돼 있는데 모든 사용자 대상 문서가 "your own API keys"만 말하고 OAuth 언급이 0회였습니다. 이미 결제 중인 구독으로 돌릴 수 있다는 걸 알 방법이 없었습니다.
- RPC 인증 서술을 사실로 정정. "인증 없음"이라 적혀 있었는데 1.0.4부터 사실이 아닙니다.
Added
docs/recipes.md— 바로 쓰는 트리거 규칙 모음(패키지명·매칭·프롬프트 포함): 카톡/문자 답장, 회의시간 자동응답, 택배 알림 정리, 은행 알림 가계부 기록, 그리고 그것들을 읽어 아침 요약. 기존엔 규칙 화면이 빈 목록 + 자유 입력란으로 열려서, 뭘 써야 하는지 아는 사람만 쓸 수 있었습니다.
OpenThumb 1.0.4 — 디버그 서버 인증 강화
포크 감사에서 남아 있던 마지막 항목(디버그 서버 자체 인증)을 닫습니다.
Security
- 디버그 서버가 loopback 포함 모든 연결에 토큰을 요구합니다. 기존엔 127.0.0.1을 "adb forward = 개발자 본인"으로 간주해 면제했는데, 실제로는 INTERNET 권한을 가진 폰 내 아무 앱이나 loopback 소켓을 열 수 있습니다. 이 RPC는
provider.export(저장된 API 키)·debug.readFile·샌드박스 명령 실행을 노출합니다. adb는run-as로 토큰을 읽을 수 있어 그대로 동작하고, 같이 설치된 앱은 그 권한이 없습니다. 노트8 실측: 인증 없는provider.export→ 401,openthumb-fleet status→ 정상. Access-Control-Allow-Origin: *제거. 폰 브라우저가 연 임의 페이지가 이 RPC를 교차출처로 호출하고 응답까지 읽을 수 있었습니다.
Changed
openthumb-fleet이 단말별 토큰을run-as로 읽어 매 호출에 실어 보냅니다($OPENTHUMB_FLEET_DIR/tokens/에 캐시). 연관배열 대신 파일 — macOS는 아직 bash 3.2라declare -A가 없고set -u에 걸립니다.
OpenThumb 1.0.2 — crash·DoS·답장 안전성
포크 자체 코드 전수 감사에서 나온 결함 수리. 크래시 경로 2건과 상대방 대화창에 원시 JSON이 발송될 수 있던 경로 1건 포함.
Fixed
- 트리거 실행 실패가 앱을 죽이지 않습니다. 실행 코루틴에 catch가 없고 스코프에 핸들러도 없어, Android 12+에서 포그라운드 서비스 시작이 거부되는 것만으로 프로세스가 죽고 알림 리스너까지 함께 사라졌습니다.
- 답장이 원시 페이로드를 보내지 않습니다. assistant 메시지 파싱이 실패하면 폴백이 저장된 JSON 문자열을 그대로 반환했고, 그게 상대 대화창으로 발송됐습니다. 이제 파싱 실패 시 아무것도 보내지 않습니다.
- 답장 실패가 실패로 기록됩니다. 세션만 있으면
ok로 남아, 사용자는 "실행됨"을 보는데 상대는 아무것도 못 받는 상태였습니다. - 답장 불가 알림은 건너뜁니다.
canReply가 구현돼 있는데 호출되지 않아, 은행·택배 푸시가 최대 10분짜리 에이전트 실행과 쿨다운을 소모하고도 보낼 곳이 없었습니다. - 디버그 서버가 악의적 요청에 죽지 않습니다. Content-Length를 무제한 신뢰해 2GB 헤더 한 줄이 OOM을 유발했고, 이는 Error라 catch에 안 잡혀 accept 루프까지 끌고 죽었습니다(앱 재시작 전까지 서버 불능). 32MB 상한 + 커넥션별 supervisor.
- 업스트림 태그명이 CI에서 코드를 실행할 수 없습니다.
contents: write권한 잡의run:에 업스트림 릴리스 태그가 직접 삽입되고 있었습니다. ensure_forward포트 계산 수리. bash가local실행 전에 모든 워드를 확장하므로 호출자의index를 읽고 있었습니다 — 변수명 우연에만 의존하던 상태로, 이름 하나만 바뀌면 전 단말 RPC가 0번 기기로 가면서 출력엔 각 단말 serial이 붙었을 것입니다.
Changed
- CI가 포크 자체 유닛 테스트를 실행합니다(34건 전부 통과). 이전엔 한 번도 실행되지 않았습니다. 상속된 실패 3종은 이름으로 제외 —
docs/upstream-test-baseline.md. - ShellCheck를
--severity=warning으로.error에서는 fleet 스크립트의 유일한 실결함(SC2318)이 걸러지고 있었습니다.
OpenThumb 1.0.1 — security patch
v1.0.0을 설치했다면 교체하세요. 그 릴리스의 유일한 에셋이 debug 빌드였고, debug 빌드는 JSON-RPC 디버그 서버를 띄웁니다. 이 서버는 0.0.0.0에 바인드되고 루프백 호출에는 토큰 검사를 면제하므로, 폰의 INTERNET 권한을 가진 아무 앱이나 provider.export를 호출해 저장된 API 키를 읽을 수 있었습니다.
Fixed
- 릴리스가 더 이상 debug APK를 배포하지 않습니다. CI가 release 변형을 빌드하고,
release-apk워크플로가 발행 시 첨부합니다. - 비-ASCII 요청 본문이 디버그 서버를 멈추게 하던 문제. Content-Length는 바이트인데 CharArray로 읽어, 한글 1자=3바이트라 읽기가 끝나지 않고 30초 소켓 타임아웃으로 죽었습니다(빈 응답, 에러 없음). 갤럭시 노트8/Android 9에서 검증.
- 프로바이더 연결 테스트가 정상 엔드포인트를 실패로 오진하던 문제.
effectiveBaseURL이 이미 적용한/v1접미를 프로브가 다시 붙여,/paas/v4같은 루트가/v4/v1/models로 조회됐습니다.
Added
- 알림이 온 대화에 에이전트가 직접 답장합니다(규칙별 옵트인, 기본 off). 알림의 빠른답장 액션을 쓰므로 카카오톡·SMS·LINE·텔레그램 등 해당 액션을 제공하는 메신저가 곧바로 양방향 채널이 됩니다. 안드로이드 공개 알림 규약만 사용 — 접근성 주입 없음.
- 요청 본문 파싱·프로브 URL 회귀 테스트 추가.
릴리스 APK는 release-apk 워크플로가 빌드해 곧 이 릴리스에 첨부됩니다.