package net.kentonc.si
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.kentonc.si.agent.LlmClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
/**
* In-app self-update. The APK ships inside this project's hub bundle under
* bin/, so the app can find a newer version without the Play Store:
*
* 1. check: GET /api/projects/<slug>?owner=<owner> — the manifest JSON
* carries `version` and `artifacts` (the .apk in the bundle)
* 2. download the raw artifact with progress into cacheDir/apks/
* 3. install via an ACTION_VIEW package-archive intent on a FileProvider URI
*
* Sideloading needs the "install unknown apps" grant for this app
* (REQUEST_INSTALL_PACKAGES); the first run walks the user into that toggle.
*/
sealed interface UpdateStatus {
/** No check yet, or checked and nothing newer on the hub. */
data object Idle : UpdateStatus
/** A newer version is on the hub; tap to download+install. */
data class Available(val version: String, val artifact: String) : UpdateStatus
data class Downloading(val version: String, val percent: Int) : UpdateStatus
/** APK in cache; the system installer was launched (or needs the grant). */
data class Ready(val version: String, val apk: File) : UpdateStatus
/** The user must flip "install unknown apps" for this app in Settings. */
data class NeedsPermission(val version: String, val apk: File) : UpdateStatus
data class Failed(val message: String) : UpdateStatus
}
class UpdateManager(
private val app: Application,
private val scope: CoroutineScope,
) {
private val _status = MutableStateFlow<UpdateStatus>(UpdateStatus.Idle)
val status: StateFlow<UpdateStatus> = _status.asStateFlow()
/** One silent check per app process is enough; retries are user-tapped. */
private var silentCheckDone = false
/** Query the hub for this project's version/artifacts and compare.
* `silent` runs hide failures (auto-check at startup) if true. */
fun check(silent: Boolean = true) {
if (silent && silentCheckDone) return
when (_status.value) {
is UpdateStatus.Downloading, is UpdateStatus.Ready, is UpdateStatus.NeedsPermission -> return
else -> {}
}
silentCheckDone = true
scope.launch {
try {
_status.value = fetch()
} catch (e: Exception) {
if (!silent) _status.value = UpdateStatus.Failed("update check failed: ${e.message}")
}
}
}
/** Tap target from the UI: download (then install), or retry / re-launch
* the permission flow, depending on the current status. */
fun start() {
val current = _status.value
when (current) {
is UpdateStatus.Available -> download(current.version, current.artifact)
is UpdateStatus.Failed, is UpdateStatus.Idle -> check(silent = false)
is UpdateStatus.NeedsPermission -> {
if (canRequestPackageInstalls()) install(current.apk, current.version)
else openPermissionSettings(current.version, current.apk)
}
is UpdateStatus.Ready -> install(current.apk, current.version)
is UpdateStatus.Downloading -> {} // already running
}
}
private suspend fun fetch(): UpdateStatus {
val hub = getHubState()
val local = currentVersion()
val (version, artifact) = hub
// Only nag when the hub is actually newer than this build.
if (!isVersionNewer(version, local)) return UpdateStatus.Idle
return UpdateStatus.Available(version, artifact)
}
/** One hub GET returning (version, artifact path inside the bundle). */
private fun getHubState(): Pair<String, String> {
val url = "${Hub.BASE}/api/projects/${Hub.SLUG}?owner=${Hub.OWNER}"
val request = Request.Builder()
.url(url)
.header("User-Agent", "${Hub.SLUG}/${currentVersion()}")
.build()
LlmClient.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw java.io.IOException("HTTP ${response.code} from hub")
val body = response.body?.string() ?: throw java.io.IOException("empty hub response")
val json = JSONObject(body)
val version = json.optString("version").ifBlank { throw java.io.IOException("hub has no version") }
val artifact = findArtifact(json, version)
?: throw java.io.IOException("no .apk artifact in the hub bundle")
return version to artifact
}
}
/** The manifest's `artifacts` field, else any bundle file ending in .apk. */
private fun findArtifact(json: JSONObject, version: String): String? {
json.optJSONArray("artifacts")?.let { artifacts ->
for (i in 0 until artifacts.length()) {
val candidate = artifacts.optString(i)
if (candidate.endsWith(".apk")) return candidate
}
}
json.optJSONArray("files")?.let { files ->
for (i in 0 until files.length()) {
val candidate = files.optString(i)
if (candidate.endsWith(".apk")) return candidate
}
}
// Known-good fallback: the versioned name we publish.
return "bin/${Hub.SLUG}-$version.apk"
}
private fun download(version: String, artifact: String) {
scope.launch {
withContext(Dispatchers.IO) {
val file = File(app.cacheDir, "apks/${Hub.SLUG}-$version.apk")
file.parentFile?.mkdirs()
try {
val url = "${Hub.BASE}/p/${Hub.SLUG}/raw/$artifact"
val request = Request.Builder()
.url(url)
.header("User-Agent", "${Hub.SLUG}/$version")
.build()
val call = LlmClient.client.newCall(request)
call.execute().use { response ->
if (!response.isSuccessful) {
throw java.io.IOException("HTTP ${response.code} downloading $url")
}
val source = response.body?.byteStream()
?: throw java.io.IOException("empty response downloading $url")
val total = response.body?.contentLength() ?: -1L
var written = 0L
var lastPercent = -1
source.use { input ->
file.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
written += read
if (total > 0) {
val percent = ((written * 100) / total).toInt()
if (percent != lastPercent) {
lastPercent = percent
_status.value = UpdateStatus.Downloading(version, percent)
}
}
}
}
}
}
if (!file.isFile || file.length() == 0L) throw java.io.IOException("download produced no file")
_status.value = UpdateStatus.Ready(version, file)
install(file, version)
} catch (e: Exception) {
_status.value = UpdateStatus.Failed("download failed: ${e.message}")
}
}
}
}
internal fun canRequestPackageInstalls(): Boolean =
app.packageManager.canRequestPackageInstalls()
internal fun currentVersion(): String =
app.packageManager.getPackageInfo(app.packageName, 0).versionName ?: "0.0.0"
private fun openPermissionSettings(version: String, apk: File) {
// ACTION_MANAGE_UNKNOWN_APP_SOURCES opens directly this app's toggle;
// returning here, the user taps the same button again to install.
app.startActivity(
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${app.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
_status.value = UpdateStatus.NeedsPermission(version, apk)
}
private fun install(apk: File, version: String) {
val uri = FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
app.startActivity(intent)
_status.value = UpdateStatus.Ready(version, apk)
}
companion object {
fun isVersionNewer(hub: String, local: String): Boolean {
val parse = { v: String -> v.split('.').mapNotNull { it.takeWhile { c -> c.isDigit() }.ifBlank { null }?.toInt() } }
val (h, l) = runCatching { parse(hub) to parse(local) }.getOrNull() ?: return false
if (h == null || l == null) return false
val size = maxOf(h.size, l.size)
for (i in 0 until size) {
val hi = h.getOrElse(i) { 0 }
val li = l.getOrElse(i) { 0 }
if (hi != li) return hi > li
}
return false
}
}
}
/** Where this project lives on the hub — one place to bump when the slug/owner changes. */
object Hub {
const val BASE = "https://notdouble.com"
const val SLUG = "superintelligence-android"
const val OWNER = "JG2HQZWB"
}