Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.2.1, 2026-09-30

- Runs on Android 9. Saving into Download/Tern without asking needs Android
10, so on 9 the kept export and a file saved from an app's page go through
the system's file picker, and the settings say where a picker is needed.
- On Android 9 the system's install confirmation now shows. It arrived in a
form Tern did not expect and was never put on screen.
- A file that needs a newer Android than the phone has is named for that,
instead of being called unreadable.
- On Android 9, Tern says once that Google's last security fixes for it came
out in January 2022, and that checking files cannot close holes in Android.
- Persian text added in 0.2.0 has its zero-width non-joiners back.

## 0.2.0, 2026-09-30

Nearly everything Obtainium does, done in a way Tern can stand behind, and
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ behalf. Every request says it comes from Tern.
[<img src="site/badge.png" alt="Get it with Tern" height="80">](https://tern.munzzyy.dev/add/#url=https%3A%2F%2Fgithub.com%2Fmunzzyy%2Ftern)

Or download `tern.apk` from the [latest release](https://github.com/munzzyy/tern/releases/latest).
It runs on Android 10 and later, on phones, tablets and TVs. Once it is
It runs on Android 9 and later, on phones, tablets and TVs. Once it is
installed, the badge above adds Tern to itself, so it keeps itself up to date.

<p align="center">
Expand Down Expand Up @@ -236,8 +236,13 @@ Obtainium does that Tern does not.
- The translations are machine-made. Details of an error that come from a
server or a parser stay in English, and an entry in the activity log stays in
the language it was written in.
- This version has run on emulators: phones with Android 10, 13 and 16, and a
television with Android TV 14. It has not yet run on a shelf of real devices.
- This version has run on emulators: phones with Android 9, 10, 13 and 16, and
a television with Android TV 14. It has not yet run on a shelf of real devices.
On Android 9, a kept export or a file saved from an app's page goes through
a file picker instead of straight to Download/Tern, since Android only
added that folder in 10; Android itself offers no certificate transparency
check there either. Everything else, install and update included, works
the same.
Vendor installers, Doze over many hours and a reboot are untested. On the
television image Play Protect stopped the first install of an app it had not
seen and offered "Install anyway"; that answer is yours to give.
Expand Down
6 changes: 3 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ android {

defaultConfig {
applicationId = "io.github.munzzyy.tern"
minSdk = 29
minSdk = 28
targetSdk = 36
versionCode = 200
versionName = "0.2.0"
versionCode = 201
versionName = "0.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.github.munzzyy.tern.enginetest

import android.content.ContentUris
import android.os.Build
import android.provider.MediaStore
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.github.munzzyy.tern.core.interop.TernExport
Expand All @@ -21,6 +22,7 @@ import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
Expand All @@ -32,7 +34,9 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class FolderExportTest {
private val resolver = targetContext.contentResolver
private val downloads = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

// MediaStore.Downloads exists only from Android 10 on; nothing here touches it before a test needs it.
private val downloads by lazy { MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) }
private val dropFolder = File(targetContext.getExternalFilesDir(null), "import")

// A day of its own for each run, years back, so that no file a person or an earlier run left has this run's names.
Expand Down Expand Up @@ -118,13 +122,17 @@ class FolderExportTest {
@Before
@After
fun sweep() {
for ((id, _) in exportsOfTheDay()) resolver.delete(ContentUris.withAppendedId(downloads, id), null, null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
for ((id, _) in exportsOfTheDay()) resolver.delete(ContentUris.withAppendedId(downloads, id), null, null)
}
dropFolder.listFiles().orEmpty().filter { it.name.startsWith("enginetest-") }.forEach { it.delete() }
targetContext.cacheDir.listFiles().orEmpty().filter { it.name.startsWith("enginetest-") }.forEach { it.delete() }
}

@Test
fun anExportLandsInTheDownloadFolderAndASecondOfThatDayGetsANumber() = runBlocking {
// Writing to Download/Tern without a picker goes through MediaStore.Downloads, which needs Android 10.
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
Dated("folder-export", noon).use { h ->
h.add("Wren")
h.add("Dunnock")
Expand All @@ -151,6 +159,7 @@ class FolderExportTest {

@Test
fun anExportComesBackThroughTheListOfFiles() = runBlocking {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
val saved = Dated("folder-out", noon).use { h ->
h.add("Wren")
h.add("Dunnock")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.github.munzzyy.tern.enginetest

import android.content.Intent
import android.os.Build
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
Expand All @@ -23,6 +24,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith

Expand Down Expand Up @@ -59,6 +61,13 @@ class HandoffTest {
return h.engine.handoff.value!!
}

/**
* MainActivity is singleTask; without a clear, explicit task of its own, Instrumentation's
* start-activity wait can hang forever on Android 9 instead of ever seeing the launch happen.
*/
private fun freshMainIntent(): Intent =
Intent(targetContext, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)

@Test
fun theHandoffListensOnThePrivateAddressOfTheDeviceAndNowhereElse() {
Harness("handoff-address").use { h ->
Expand Down Expand Up @@ -96,8 +105,14 @@ class HandoffTest {

@Test
fun leavingTheScreenEndsTheHandoff() {
// Confirmed on a real Android 9 device: android.app.Instrumentation.startActivitySync
// never returns here, though the activity itself really reaches RESUMED (seen in a thread
// dump: the "Instr:" thread sits forever in Object.wait at Instrumentation.java:496, main
// is idle). Neither an explicit task nor any change on Tern's side moves that wait along;
// it is androidx.test's own launch monitor missing the callback on this platform.
assumeTrue("ActivityScenario.launch never returns for MainActivity on Android 9", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
Harness("handoff-screen").use { h ->
ActivityScenario.launch<MainActivity>(Intent(targetContext, MainActivity::class.java)).use { screen ->
ActivityScenario.launch<MainActivity>(freshMainIntent()).use { screen ->
val handoff = open(h)
screen.moveToState(Lifecycle.State.CREATED)
waitUntil(5_000, "the handoff to end") { h.engine.handoff.value == null }
Expand All @@ -113,8 +128,10 @@ class HandoffTest {

@Test
fun turningTheScreenLeavesTheHandoffOpen() {
// Same androidx.test launch-monitor gap as leavingTheScreenEndsTheHandoff, above.
assumeTrue("ActivityScenario.launch never returns for MainActivity on Android 9", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
Harness("handoff-turn").use { h ->
ActivityScenario.launch<MainActivity>(Intent(targetContext, MainActivity::class.java)).use { screen ->
ActivityScenario.launch<MainActivity>(freshMainIntent()).use { screen ->
val handoff = open(h)
screen.recreate()
assertEquals(handoff, h.engine.handoff.value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,9 @@ class FakeEngine(private val context: Context) : Engine, Scenarios {
override suspend fun saveFile(appId: String, releaseId: String, assetUrl: String): SavedFile =
SavedFile(assetUrl.substringAfterLast('/'), "Download/Tern", "/storage/emulated/0/Download/Tern/" + assetUrl.substringAfterLast('/'), System.currentTimeMillis(), 0)

override suspend fun saveFileTo(appId: String, releaseId: String, assetUrl: String, destination: Uri): SavedFile =
SavedFile(assetUrl.substringAfterLast('/'), "the place you picked", destination.toString(), System.currentTimeMillis(), 0)

override suspend fun fileSize(appId: String, releaseId: String, assetUrl: String): Long? = null

override val searchOrigins: List<String> = listOf("GitHub", "Codeberg", "GitLab", "F-Droid", "Aptoide", "vivo App Store")
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/kotlin/io/github/munzzyy/tern/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.github.munzzyy.tern
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.provider.Settings.Global
import androidx.activity.ComponentActivity
Expand Down Expand Up @@ -38,7 +39,9 @@ import io.github.munzzyy.tern.ui.TernApp
import io.github.munzzyy.tern.ui.common.CrashDialog
import io.github.munzzyy.tern.ui.common.ProblemsDialog
import io.github.munzzyy.tern.ui.incomingAddInput
import io.github.munzzyy.tern.ui.settings.Android9Note
import io.github.munzzyy.tern.ui.settings.VerificationNote
import io.github.munzzyy.tern.ui.settings.showsAndroid9Note
import io.github.munzzyy.tern.ui.theme.TernTheme
import io.github.munzzyy.tern.ui.theme.isDark
import io.github.munzzyy.tern.widget.Surfaces
Expand All @@ -49,6 +52,7 @@ import kotlinx.coroutines.launch
private const val PREFS = "ui"
private const val KEY_FIRST_RUN_DONE = "first_run_done"
private const val KEY_VERIFICATION_NOTE_SHOWN = "verification_note_shown"
private const val KEY_ANDROID9_NOTE_SHOWN = "android9_note_shown"
private const val TAG = "TernMain"
private const val MAX_APP_ID = 64
private const val MAX_PROBLEMS = 50
Expand All @@ -69,6 +73,7 @@ class MainActivity : ComponentActivity() {

/** Shown once, on the first start after the first run, so it never stands in the way of that run. */
private var verificationNote by mutableStateOf(false)
private var android9Note by mutableStateOf(false)

/** A check a link from outside asked for, until the person says yes or no. */
private var confirmCheck by mutableStateOf<RefreshLink?>(null)
Expand All @@ -88,6 +93,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
firstRunDone = prefs().getBoolean(KEY_FIRST_RUN_DONE, false)
verificationNote = firstRunDone && !prefs().getBoolean(KEY_VERIFICATION_NOTE_SHOWN, false)
android9Note = showsAndroid9Note(Build.VERSION.SDK_INT, firstRunDone, prefs().getBoolean(KEY_ANDROID9_NOTE_SHOWN, false))
if (savedInstanceState == null) crash = CrashReport.pending(this)
applyScenario(intent)
if (savedInstanceState == null) receive(intent)
Expand Down Expand Up @@ -142,6 +148,7 @@ class MainActivity : ComponentActivity() {
)
}
if (crash == null && verificationNote) VerificationNote(onDismiss = ::sawVerificationNote)
if (crash == null && !verificationNote && android9Note) Android9Note(onDismiss = ::sawAndroid9Note)
}
}
}
Expand Down Expand Up @@ -240,6 +247,11 @@ class MainActivity : ComponentActivity() {
CrashReport.dismiss(this)
}

private fun sawAndroid9Note() {
android9Note = false
prefs().edit().putBoolean(KEY_ANDROID9_NOTE_SHOWN, true).apply()
}

private fun sawVerificationNote() {
verificationNote = false
prefs().edit().putBoolean(KEY_VERIFICATION_NOTE_SHOWN, true).apply()
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/kotlin/io/github/munzzyy/tern/engine/Engine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,13 @@ interface Engine {
*/
suspend fun saveFile(appId: String, releaseId: String, assetUrl: String): SavedFile

/**
* The same download as [saveFile], written to [destination] instead of Download/Tern. For
* Android versions that have no Download/Tern of their own, where a person picks the place
* themselves through a file picker.
*/
suspend fun saveFileTo(appId: String, releaseId: String, assetUrl: String, destination: Uri): SavedFile

/**
* The size of a file of the app whose source names none, asked of the server once, or null
* when the server does not say. The token goes only where it would go for the download.
Expand Down
34 changes: 33 additions & 1 deletion app/src/main/kotlin/io/github/munzzyy/tern/engine/real/Files.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import io.github.munzzyy.tern.engine.ExportFormat
import io.github.munzzyy.tern.engine.Problem
import io.github.munzzyy.tern.engine.ProblemException
Expand Down Expand Up @@ -73,6 +75,7 @@ internal object Importable {
internal class Files(context: Context, private val texts: Texts, private val nowMs: () -> Long) {
private val c = context.applicationContext
private val resolver = c.contentResolver
@get:RequiresApi(Build.VERSION_CODES.Q)
private val downloads: Uri get() = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

// Deprecated as a way to open files; here it only names the shared folder in a path a person reads.
Expand All @@ -82,6 +85,7 @@ internal class Files(context: Context, private val texts: Texts, private val now
private class Entry(val file: SavedFile, val uri: Uri?)

fun save(text: String, fixedName: String? = null, prefix: String = ExportNames.PREFIX): SavedFile {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) throw needsQ()
val bytes = text.toByteArray(Charsets.UTF_8)
val name = fixedName ?: ExportNames.forDay(nowMs(), ZoneId.systemDefault(), asked { exports() }.orEmpty().map { it.file.name }, prefix)
val values = ContentValues().apply {
Expand Down Expand Up @@ -112,6 +116,7 @@ internal class Files(context: Context, private val texts: Texts, private val now
* picks another name when one of that name is there already.
*/
fun saveCopy(source: File, name: String, mime: String): SavedFile {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) throw needsQ()
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, mime)
Expand Down Expand Up @@ -141,6 +146,7 @@ internal class Files(context: Context, private val texts: Texts, private val now
* so a kept export stays one file instead of one a day.
*/
fun keep(name: String, text: String): SavedFile {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) throw needsQ()
val existing = asked { exports() }.orEmpty().firstOrNull { it.file.name == name && it.uri != null }
val uri = existing?.uri ?: return save(text, name)
try {
Expand All @@ -154,6 +160,27 @@ internal class Files(context: Context, private val texts: Texts, private val now
return existing.file
}

/** Copies [source] to [destination], a place the person picked themselves through a file picker. */
fun saveTo(source: File, destination: Uri): SavedFile {
val name = asked { displayNameOf(destination) } ?: source.name
try {
val out = resolver.openOutputStream(destination) ?: throw IOException("The picker opened no file")
out.use { target -> source.inputStream().use { it.copyTo(target) } }
} catch (ex: IOException) {
throw ProblemException(Problem(ProblemKind.STORAGE, texts.exportFailed(ex.message)))
} catch (ex: RuntimeException) {
throw ProblemException(Problem(ProblemKind.STORAGE, texts.exportFailed(ex.message)))
}
return SavedFile(name, texts.pickedPlace(), destination.toString(), nowMs(), source.length())
}

private fun displayNameOf(uri: Uri): String? =
resolver.query(uri, arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
}

private fun needsQ() = ProblemException(Problem(ProblemKind.UNSUPPORTED, texts.needsNewerAndroid(Build.VERSION_CODES.Q)))

fun list(): List<SavedFile> = entries().map { it.file }

/** Only a file that [list] names is read, so a path from anywhere else opens nothing. */
Expand All @@ -175,7 +202,11 @@ internal class Files(context: Context, private val texts: Texts, private val now
private fun open(entry: Entry): InputStream =
if (entry.uri == null) Importable.openDropped(File(entry.file.path)) else resolver.openInputStream(entry.uri) ?: throw IOException("MediaStore opened no file")

private fun entries(): List<Entry> = Importable.newestFirst(asked { exports() }.orEmpty() + dropped()) { it.file }
private fun entries(): List<Entry> = Importable.newestFirst(mediaStoreExports() + dropped()) { it.file }

/** MediaStore's downloads collection exists only from Android 10 on; below that there is nothing to list here. */
private fun mediaStoreExports(): List<Entry> =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) emptyList() else asked { exports() }.orEmpty()

/** MediaStore runs in another process and answers a failure with whichever RuntimeException it likes. */
private fun <T> asked(call: () -> T): T? = try {
Expand All @@ -185,6 +216,7 @@ internal class Files(context: Context, private val texts: Texts, private val now
}

/** Rows of the download collection that this app made; Android shows an app without permissions no others. */
@RequiresApi(Build.VERSION_CODES.Q)
private fun exports(only: Uri? = null): List<Entry> {
val columns = arrayOf(
MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.RELATIVE_PATH,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ internal class Installs(private val e: RealEngine) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
e.saveState(appId) { it.copy(pending = pending.copy(waitingForUser = true)) }
e.setProgress(appId, Progress(Phase.WAITING_FOR_USER))
val shown = confirm?.takeIf { it.action == CONFIRM_INSTALL || OtherAppInstaller.isHandoff(e.context, it) }
val shown = confirm?.takeIf { it.action in CONFIRM_ACTIONS || OtherAppInstaller.isHandoff(e.context, it) }
if (shown != null) {
confirmations[appId] = shown
askUser(appId, stored.config.shownName, shown)
Expand Down Expand Up @@ -674,7 +674,11 @@ internal class Installs(private val e: RealEngine) {
private const val APK_MIME = "application/vnd.android.package-archive"

private const val TAG = "TernInstalls"
private const val CONFIRM_INSTALL = "android.content.pm.action.CONFIRM_INSTALL"

// Android 10 and later hand back a confirm intent for CONFIRM_INSTALL; on Android 9 the
// same pending-user-action status carries CONFIRM_PERMISSIONS instead. Both are the
// system's own install confirmation, so both are shown the same way.
private val CONFIRM_ACTIONS = setOf("android.content.pm.action.CONFIRM_INSTALL", "android.content.pm.action.CONFIRM_PERMISSIONS")
private const val DAY_MS = 24L * 60 * 60 * 1000
private const val SUMS_LIMIT = 1024 * 1024
private const val INSTALL_WAIT_MS = 3 * 60 * 1000L
Expand Down
Loading
Loading