release: 1.29.5
release: 1.29.5
This commit is contained in:
@@ -26,11 +26,12 @@ android {
|
|||||||
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
||||||
versionName = (project.findProperty("versionName") as? String) ?: "1.0-dev"
|
versionName = (project.findProperty("versionName") as? String) ?: "1.0-dev"
|
||||||
|
|
||||||
// Embed git commit SHA for the About card in Settings
|
// Use a fixed prefix: git --short varies with the checkout's object graph
|
||||||
|
// (shallow GitHub Actions vs. full F-Droid clone), breaking reproducible APKs.
|
||||||
val gitSha =
|
val gitSha =
|
||||||
providers.exec {
|
providers.exec {
|
||||||
commandLine("git", "rev-parse", "--short", "HEAD")
|
commandLine("git", "rev-parse", "HEAD")
|
||||||
}.standardOutput.asText.get().trim()
|
}.standardOutput.asText.get().trim().take(7)
|
||||||
buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
|
buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import com.m57.hermescontrol.data.local.AuthManager
|
|||||||
import com.m57.hermescontrol.data.local.SessionListCacheStore
|
import com.m57.hermescontrol.data.local.SessionListCacheStore
|
||||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
|
||||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -41,9 +40,6 @@ class HermesControlApp :
|
|||||||
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
||||||
// cold backend). Fire-and-forget; never blocks UI startup.
|
// cold backend). Fire-and-forget; never blocks UI startup.
|
||||||
AnalyticsPreloader.preload(this@HermesControlApp)
|
AnalyticsPreloader.preload(this@HermesControlApp)
|
||||||
// Issue #890: silent once-per-version update check right after launch,
|
|
||||||
// so the chat screen can show an update banner without user interaction.
|
|
||||||
UpdateNoticeManager.checkOnLaunch()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||||
@@ -26,6 +27,7 @@ import com.m57.hermescontrol.theme.HermesControlTheme
|
|||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.LoadingState
|
import com.m57.hermescontrol.ui.common.LoadingState
|
||||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -131,6 +133,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
|
||||||
|
UpdateNoticeManager.checkOnLaunch()
|
||||||
|
}
|
||||||
NotificationHelper.setAppForeground(this, true)
|
NotificationHelper.setAppForeground(this, true)
|
||||||
ExternalActivityLifecycleGuard.onHostResumed()
|
ExternalActivityLifecycleGuard.onHostResumed()
|
||||||
NotificationHelper.stop(this)
|
NotificationHelper.stop(this)
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ interface ChatMessageDao {
|
|||||||
suspend fun upsert(message: ChatMessageEntity) {
|
suspend fun upsert(message: ChatMessageEntity) {
|
||||||
val existing = getMessage(message.id)
|
val existing = getMessage(message.id)
|
||||||
val restId = message.restId ?: existing?.restId
|
val restId = message.restId ?: existing?.restId
|
||||||
val canonicalOrder = canonicalMessageOrder(restId ?: message.id, message.sessionId)
|
val canonicalOrder =
|
||||||
|
if (message.isSessionStartMarker()) {
|
||||||
|
-1L
|
||||||
|
} else {
|
||||||
|
canonicalMessageOrder(restId ?: message.id, message.sessionId)
|
||||||
|
}
|
||||||
writeMessage(
|
writeMessage(
|
||||||
message.copy(
|
message.copy(
|
||||||
restId = restId,
|
restId = restId,
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ data class ChatMessageEntity(
|
|||||||
val sortOrder: Long = 0,
|
val sortOrder: Long = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun ChatMessageEntity.isSessionStartMarker(): Boolean =
|
||||||
|
role == "SYSTEM" && (content == "Session created" || content == "Session branched")
|
||||||
|
|
||||||
/** Only the exact session prefix and a nonnegative decimal suffix identify a canonical row. */
|
/** Only the exact session prefix and a nonnegative decimal suffix identify a canonical row. */
|
||||||
internal fun canonicalMessageOrder(
|
internal fun canonicalMessageOrder(
|
||||||
id: String,
|
id: String,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
|||||||
tps = tps,
|
tps = tps,
|
||||||
completionId = completionId,
|
completionId = completionId,
|
||||||
restId = restId,
|
restId = restId,
|
||||||
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 },
|
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() },
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ object AppUpdateCache {
|
|||||||
* Session-only dismissal of the chat banner. Snapshot-backed so the chat
|
* Session-only dismissal of the chat banner. Snapshot-backed so the chat
|
||||||
* screen recomposes when it flips; dies with the process.
|
* screen recomposes when it flips; dies with the process.
|
||||||
*/
|
*/
|
||||||
var dismissed by mutableStateOf(false)
|
private var dismissedTags by mutableStateOf<Set<String>>(emptySet())
|
||||||
private set
|
|
||||||
|
fun isDismissed(tag: String): Boolean = tag in dismissedTags
|
||||||
|
|
||||||
/** Whether the full-screen / in-place update dialog should be visible. */
|
/** Whether the full-screen / in-place update dialog should be visible. */
|
||||||
var isDialogVisible by mutableStateOf(false)
|
var isDialogVisible by mutableStateOf(false)
|
||||||
@@ -32,8 +33,8 @@ object AppUpdateCache {
|
|||||||
_state.value = state
|
_state.value = state
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismiss() {
|
fun dismiss(tag: String) {
|
||||||
dismissed = true
|
dismissedTags = dismissedTags + tag
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDialog() {
|
fun showDialog() {
|
||||||
@@ -46,7 +47,7 @@ object AppUpdateCache {
|
|||||||
|
|
||||||
/** Test hook: clear state and dismissal between tests. */
|
/** Test hook: clear state and dismissal between tests. */
|
||||||
internal fun reset() {
|
internal fun reset() {
|
||||||
dismissed = false
|
dismissedTags = emptySet()
|
||||||
isDialogVisible = false
|
isDialogVisible = false
|
||||||
_state.value = AppUpdateState.Idle
|
_state.value = AppUpdateState.Idle
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ package com.m57.hermescontrol.data.update
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -79,17 +84,28 @@ fun selectLatestUpdate(
|
|||||||
includeReleaseCandidates: Boolean = false,
|
includeReleaseCandidates: Boolean = false,
|
||||||
): UpdateInfo? =
|
): UpdateInfo? =
|
||||||
releases
|
releases
|
||||||
.filter { !it.draft && it.apkAsset != null }
|
.filter(::hasUsableApk)
|
||||||
.filter {
|
.filter { isEligibleRelease(it, includeReleaseCandidates) }
|
||||||
if (!it.prerelease) {
|
.reduceOrNull { best, candidate ->
|
||||||
true
|
|
||||||
} else {
|
|
||||||
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
|
|
||||||
}
|
|
||||||
}.reduceOrNull { best, candidate ->
|
|
||||||
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
|
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasUsableApk(release: UpdateInfo): Boolean =
|
||||||
|
!release.draft && release.apkAsset?.browserDownloadUrl?.toHttpUrlOrNull()?.let {
|
||||||
|
it.scheme == "http" || it.scheme == "https"
|
||||||
|
} == true
|
||||||
|
|
||||||
|
private fun isEligibleRelease(
|
||||||
|
release: UpdateInfo,
|
||||||
|
includeReleaseCandidates: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
val version = parseVersion(release.tagName) ?: return false
|
||||||
|
val isRc = version.prerelease.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
|
||||||
|
val isStable = version.prerelease.isEmpty() && !release.prerelease
|
||||||
|
if (isStable) return true
|
||||||
|
return includeReleaseCandidates && isRc
|
||||||
|
}
|
||||||
|
|
||||||
private data class ParsedVersion(
|
private data class ParsedVersion(
|
||||||
val core: List<Int>,
|
val core: List<Int>,
|
||||||
val prerelease: List<String>,
|
val prerelease: List<String>,
|
||||||
@@ -161,17 +177,16 @@ open class AppUpdateChecker(
|
|||||||
*/
|
*/
|
||||||
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
|
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (includeReleaseCandidates) {
|
val latest = get("$apiBaseUrl/releases/latest")?.let(::parseRelease)
|
||||||
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
|
if (!includeReleaseCandidates && latest != null && selectLatestUpdate(listOf(latest)) != null) {
|
||||||
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
|
return@withContext latest
|
||||||
} else {
|
|
||||||
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
|
|
||||||
parseUpdateInfo(body)
|
|
||||||
}
|
}
|
||||||
|
val releases = fetchReleasePages() ?: return@withContext null
|
||||||
|
selectLatestUpdate(releases + listOfNotNull(latest), includeReleaseCandidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET [url] as JSON text, or null on any non-2xx response. */
|
/** GET a GitHub URL. 404 means absent; other HTTP/network failures stay visible. */
|
||||||
private fun get(url: String): String? {
|
private fun get(url: String): ReleasePage? {
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
@@ -179,11 +194,45 @@ open class AppUpdateChecker(
|
|||||||
.header("Accept", "application/vnd.github+json")
|
.header("Accept", "application/vnd.github+json")
|
||||||
.build()
|
.build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) return null
|
if (response.code == HTTP_NOT_FOUND) return null
|
||||||
return response.body.string().orEmpty()
|
if (!response.isSuccessful) throw IOException("GitHub releases request failed (${response.code})")
|
||||||
|
return ReleasePage(response.body.string().orEmpty(), response.nextPageUrl())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun fetchReleasePages(): List<UpdateInfo>? {
|
||||||
|
var nextUrl: String? = "$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE&page=1"
|
||||||
|
val releases = mutableListOf<UpdateInfo>()
|
||||||
|
while (nextUrl != null) {
|
||||||
|
val page =
|
||||||
|
get(nextUrl)
|
||||||
|
?: return if (releases.isEmpty()) null else throw IOException("Incomplete GitHub releases list")
|
||||||
|
val parsed = parseReleaseList(page.body) ?: throw IOException("GitHub returned malformed release metadata")
|
||||||
|
releases += parsed
|
||||||
|
nextUrl = page.nextUrl
|
||||||
|
}
|
||||||
|
return releases
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRelease(page: ReleasePage): UpdateInfo =
|
||||||
|
parseUpdateInfo(page.body) ?: throw IOException("GitHub returned malformed release metadata")
|
||||||
|
|
||||||
|
private fun Response.nextPageUrl(): String? {
|
||||||
|
val link = header("Link") ?: return null
|
||||||
|
val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null
|
||||||
|
val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL")
|
||||||
|
val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base URL")
|
||||||
|
if (next.host != base.host || next.encodedPath != base.encodedPath.trimEnd('/') + "/releases") {
|
||||||
|
throw IOException("GitHub returned an untrusted pagination URL")
|
||||||
|
}
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ReleasePage(
|
||||||
|
val body: String,
|
||||||
|
val nextUrl: String?,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a release APK asset to [dest], reporting progress 0..1 via
|
* Stream a release APK asset to [dest], reporting progress 0..1 via
|
||||||
* [onProgress]. True on success; false (with the partial file deleted)
|
* [onProgress]. True on success; false (with the partial file deleted)
|
||||||
@@ -194,11 +243,48 @@ open class AppUpdateChecker(
|
|||||||
dest: File,
|
dest: File,
|
||||||
onProgress: (Float) -> Unit,
|
onProgress: (Float) -> Unit,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
withContext(Dispatchers.IO) {
|
suspendCancellableCoroutine { continuation ->
|
||||||
val request = Request.Builder().url(url).build()
|
val request =
|
||||||
try {
|
try {
|
||||||
client.newCall(request).execute().use { response ->
|
val httpUrl =
|
||||||
if (!response.isSuccessful) return@withContext false
|
url.toHttpUrlOrNull()?.takeIf { it.scheme == "http" || it.scheme == "https" }
|
||||||
|
?: run {
|
||||||
|
failDownload(continuation, dest)
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
Request.Builder().url(httpUrl).build()
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
failDownload(continuation, dest)
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
val call =
|
||||||
|
try {
|
||||||
|
client.newCall(request)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
failDownload(continuation, dest)
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
call.cancel()
|
||||||
|
deletePartial(dest)
|
||||||
|
}
|
||||||
|
val callback =
|
||||||
|
object : Callback {
|
||||||
|
override fun onFailure(
|
||||||
|
call: Call,
|
||||||
|
e: IOException,
|
||||||
|
) {
|
||||||
|
failDownload(continuation, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(
|
||||||
|
call: Call,
|
||||||
|
response: Response,
|
||||||
|
) {
|
||||||
|
var succeeded = false
|
||||||
|
try {
|
||||||
|
response.use {
|
||||||
|
if (response.isSuccessful) {
|
||||||
val body = response.body
|
val body = response.body
|
||||||
val total = body.contentLength()
|
val total = body.contentLength()
|
||||||
dest.outputStream().use { out ->
|
dest.outputStream().use { out ->
|
||||||
@@ -216,19 +302,44 @@ open class AppUpdateChecker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
succeeded = true
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
dest.delete()
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Includes response/body close failures; never report success before close.
|
||||||
|
}
|
||||||
|
if (succeeded) {
|
||||||
|
if (continuation.isActive) continuation.resume(true) { _, _, _ -> }
|
||||||
|
} else {
|
||||||
|
failDownload(continuation, dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
call.enqueue(callback)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
failDownload(continuation, dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failDownload(
|
||||||
|
continuation: kotlinx.coroutines.CancellableContinuation<Boolean>,
|
||||||
|
dest: File,
|
||||||
|
) {
|
||||||
|
deletePartial(dest)
|
||||||
|
if (continuation.isActive) continuation.resume(false) { _, _, _ -> }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deletePartial(dest: File) {
|
||||||
|
runCatching { dest.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val DEFAULT_BUFFER_SIZE = 8192
|
const val DEFAULT_BUFFER_SIZE = 8192
|
||||||
|
const val HTTP_NOT_FOUND = 404
|
||||||
|
|
||||||
/** How many recent releases the opt-in RC scan inspects. */
|
const val RELEASE_LIST_PAGE_SIZE = 100
|
||||||
const val RELEASE_LIST_PAGE_SIZE = 20
|
val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ sealed interface AppUpdateState {
|
|||||||
|
|
||||||
data class Error(
|
data class Error(
|
||||||
val message: String,
|
val message: String,
|
||||||
|
val isCheckError: Boolean = false,
|
||||||
) : AppUpdateState
|
) : AppUpdateState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,31 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startup update check (issue #890): one silent GitHub ping per installed
|
* Shared release check (issue #890): checks after launch and on foreground
|
||||||
* version, fired right after launch, so the chat screen can show an update
|
* return, with a short process-local debounce so the chat screen can show an
|
||||||
* banner without the user ever opening the About tab.
|
* update banner without the user ever opening the About tab.
|
||||||
*
|
*
|
||||||
* The result lands in [AppUpdateCache] (consumed by the chat banner and
|
* The result lands in [AppUpdateCache] (consumed by the chat banner and
|
||||||
* adopted by the About tab) and the latest tag is persisted via
|
* adopted by the About tab) and the latest tag is persisted via
|
||||||
* [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a
|
* [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a
|
||||||
* later launch. Failures leave the cache Idle — the About tab still has its
|
* later launch. Failed checks remain visible and can be retried from the About
|
||||||
* own manual retry path.
|
* tab or dialog without persisting a false successful-check marker.
|
||||||
*/
|
*/
|
||||||
object UpdateNoticeManager {
|
object UpdateNoticeManager {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val checkLock = Any()
|
||||||
|
private var activeCheck: Job? = null
|
||||||
|
private var lastSuccessfulCheckAt: Long? = null
|
||||||
|
private var lastFailedCheckAt: Long? = null
|
||||||
|
private var channelGeneration = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release-only feature (issue #890): the launch check and chat banner are
|
* Release-only feature (issue #890): the launch check and chat banner are
|
||||||
@@ -32,46 +40,45 @@ object UpdateNoticeManager {
|
|||||||
var enabled: Boolean = !BuildConfig.DEBUG
|
var enabled: Boolean = !BuildConfig.DEBUG
|
||||||
internal set
|
internal set
|
||||||
|
|
||||||
/** Release check interval (24 hours). */
|
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
|
||||||
const val CHECK_INTERVAL_MS: Long = 24 * 60 * 60 * 1000L
|
const val CHECK_INTERVAL_MS: Long = 5 * 60 * 1000L
|
||||||
|
const val FAILURE_BACKOFF_MS: Long = 30 * 1000L
|
||||||
|
|
||||||
/** Run from Application.onCreate, after AuthManager.init. Checks at most once every 24h. */
|
/** Run after preferences are ready; failed checks never suppress the next foreground retry. */
|
||||||
fun checkOnLaunch(
|
fun checkOnLaunch(
|
||||||
checker: AppUpdateChecker = AppUpdateChecker(),
|
checker: AppUpdateChecker = AppUpdateChecker(),
|
||||||
currentVersion: String = BuildConfig.VERSION_NAME,
|
currentVersion: String = BuildConfig.VERSION_NAME,
|
||||||
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
now: Long = System.currentTimeMillis(),
|
now: Long = System.currentTimeMillis(),
|
||||||
|
force: Boolean = false,
|
||||||
) {
|
) {
|
||||||
if (!enabled) return
|
if (!enabled && !force) return
|
||||||
val lastCheck = AuthManager.getLastUpdateCheckTimestamp()
|
synchronized(checkLock) {
|
||||||
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
|
if (activeCheck?.isActive == true) return
|
||||||
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
|
if (!force && lastSuccessfulCheckAt?.let { now - it < CHECK_INTERVAL_MS } == true) return
|
||||||
|
if (!force && lastFailedCheckAt?.let { now - it < FAILURE_BACKOFF_MS } == true) return
|
||||||
// Captured once so the check and the notice path agree on the channel.
|
|
||||||
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
|
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
|
||||||
|
val generation = channelGeneration
|
||||||
|
val previousState = AppUpdateCache.state.value
|
||||||
|
if (force ||
|
||||||
|
previousState !is AppUpdateState.UpdateAvailable
|
||||||
|
) {
|
||||||
|
AppUpdateCache.update(AppUpdateState.Checking)
|
||||||
|
}
|
||||||
|
activeCheck =
|
||||||
scope.launch(ioDispatcher) {
|
scope.launch(ioDispatcher) {
|
||||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
val thisJob = currentCoroutineContext()[Job]
|
||||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
var successful = false
|
||||||
val result =
|
var failed = false
|
||||||
try {
|
try {
|
||||||
checker.fetchLatestRelease(includeReleaseCandidates)
|
val info = checker.fetchLatestRelease(includeReleaseCandidates)
|
||||||
} catch (e: CancellationException) {
|
val apk = info?.apkAsset
|
||||||
throw e
|
|
||||||
} catch (e: IOException) {
|
|
||||||
return@launch
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val info = result ?: return@launch
|
|
||||||
// The user may have switched back to stable while this request was in
|
|
||||||
// flight — an RC result must never be published on the stable channel.
|
|
||||||
if (isReleaseCandidateVersion(info.tagName) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val apk = info.apkAsset ?: return@launch
|
|
||||||
val state =
|
val state =
|
||||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
if (info == null) {
|
||||||
|
AppUpdateState.Error(NO_RELEASE_ERROR, isCheckError = true)
|
||||||
|
} else if (apk == null) {
|
||||||
|
AppUpdateState.Error(NO_APK_ERROR, isCheckError = true)
|
||||||
|
} else if (isNewerVersion(info.tagName, currentVersion)) {
|
||||||
AppUpdateState.UpdateAvailable(
|
AppUpdateState.UpdateAvailable(
|
||||||
latestTag = info.tagName,
|
latestTag = info.tagName,
|
||||||
apkUrl = apk.browserDownloadUrl,
|
apkUrl = apk.browserDownloadUrl,
|
||||||
@@ -81,8 +88,105 @@ object UpdateNoticeManager {
|
|||||||
} else {
|
} else {
|
||||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||||
}
|
}
|
||||||
|
var resultRejected = false
|
||||||
|
synchronized(checkLock) {
|
||||||
|
if (activeCheck !== thisJob || generation != channelGeneration) return@synchronized
|
||||||
|
val currentChannel = AuthManager.isCheckingReleaseCandidateUpdates()
|
||||||
|
if (includeReleaseCandidates != currentChannel ||
|
||||||
|
(info != null && isReleaseCandidateVersion(info.tagName) && !currentChannel)
|
||||||
|
) {
|
||||||
|
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||||
|
AppUpdateCache.update(previousState)
|
||||||
|
}
|
||||||
|
resultRejected = true
|
||||||
|
return@synchronized
|
||||||
|
}
|
||||||
AppUpdateCache.update(state)
|
AppUpdateCache.update(state)
|
||||||
|
if (state is AppUpdateState.UpdateAvailable || state is AppUpdateState.UpToDate) {
|
||||||
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
||||||
|
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||||
|
AuthManager.setLastUpdateCheckTimestamp(System.currentTimeMillis())
|
||||||
|
successful = true
|
||||||
|
} else {
|
||||||
|
failed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resultRejected) return@launch
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: IOException) {
|
||||||
|
synchronized(checkLock) {
|
||||||
|
if (activeCheck === thisJob && generation == channelGeneration) {
|
||||||
|
failed = true
|
||||||
|
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||||
|
AppUpdateCache.update(AppUpdateState.Error(NETWORK_ERROR, isCheckError = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
synchronized(checkLock) {
|
||||||
|
if (activeCheck === thisJob && generation == channelGeneration) {
|
||||||
|
failed = true
|
||||||
|
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||||
|
AppUpdateCache.update(
|
||||||
|
AppUpdateState.Error(GENERIC_CHECK_ERROR, isCheckError = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
synchronized(checkLock) {
|
||||||
|
if (activeCheck === thisJob) {
|
||||||
|
if (successful) lastSuccessfulCheckAt = now
|
||||||
|
if (failed) lastFailedCheckAt = now
|
||||||
|
activeCheck = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NETWORK_ERROR = "Network error — check your connection"
|
||||||
|
private const val GENERIC_CHECK_ERROR = "Couldn't check for updates"
|
||||||
|
private const val NO_RELEASE_ERROR = "No release found yet"
|
||||||
|
private const val NO_APK_ERROR = "Release has no APK asset"
|
||||||
|
|
||||||
|
/** Explicit check action; shares in-flight work but bypasses the local debounce. */
|
||||||
|
fun checkNow(
|
||||||
|
checker: AppUpdateChecker,
|
||||||
|
currentVersion: String,
|
||||||
|
ioDispatcher: CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
checkOnLaunch(checker, currentVersion, ioDispatcher, force = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the channel choice and invalidate in-flight checks as one operation. */
|
||||||
|
fun channelChanged(enabled: Boolean) {
|
||||||
|
synchronized(checkLock) {
|
||||||
|
AuthManager.setCheckReleaseCandidateUpdates(enabled)
|
||||||
|
channelGeneration++
|
||||||
|
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||||
|
AppUpdateCache.update(AppUpdateState.Idle)
|
||||||
|
}
|
||||||
|
activeCheck?.cancel()
|
||||||
|
activeCheck = null
|
||||||
|
lastSuccessfulCheckAt = null
|
||||||
|
lastFailedCheckAt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: reset process-local admission state between tests. */
|
||||||
|
internal fun resetForTests() {
|
||||||
|
synchronized(checkLock) {
|
||||||
|
activeCheck?.cancel()
|
||||||
|
activeCheck = null
|
||||||
|
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||||
|
AppUpdateCache.update(AppUpdateState.Idle)
|
||||||
|
}
|
||||||
|
lastSuccessfulCheckAt = null
|
||||||
|
lastFailedCheckAt = null
|
||||||
|
channelGeneration++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,12 +198,9 @@ object UpdateNoticeManager {
|
|||||||
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
|
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
|
||||||
if (!enabled) return null
|
if (!enabled) return null
|
||||||
val dismissed = AuthManager.getDismissedUpdateTag()
|
val dismissed = AuthManager.getDismissedUpdateTag()
|
||||||
val candidateTag =
|
val candidateTag = (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag ?: return null
|
||||||
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag
|
|
||||||
?: AuthManager.getLastKnownLatestTag()
|
|
||||||
?: return null
|
|
||||||
|
|
||||||
if (candidateTag == dismissed) return null
|
if (candidateTag == dismissed || AppUpdateCache.isDismissed(candidateTag)) return null
|
||||||
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ private const val MAX_ITERATIONS_SYSTEM_MARKER =
|
|||||||
*/
|
*/
|
||||||
internal fun ChatMessage.isUserTurnBoundary(): Boolean =
|
internal fun ChatMessage.isUserTurnBoundary(): Boolean =
|
||||||
role == MessageRole.USER &&
|
role == MessageRole.USER &&
|
||||||
displayKind == null &&
|
(displayKind == null || displayKind == "steer") &&
|
||||||
!content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
!content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -164,7 +164,11 @@ internal fun mapServerMessages(
|
|||||||
// Keep the actual @file:/@image: token here: unlike the dedupe path,
|
// Keep the actual @file:/@image: token here: unlike the dedupe path,
|
||||||
// REST-only hydration cannot prove that reference was not user-authored.
|
// REST-only hydration cannot prove that reference was not user-authored.
|
||||||
var finalContent =
|
var finalContent =
|
||||||
if (role == MessageRole.USER) stripGatewayAttachedContext(rawContent) else rawContent
|
if (role == MessageRole.USER) {
|
||||||
|
stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent))
|
||||||
|
} else {
|
||||||
|
rawContent
|
||||||
|
}
|
||||||
var attachments: List<Attachment>? = null
|
var attachments: List<Attachment>? = null
|
||||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||||
val items = HostMediaExtractor.extract(rawContent)
|
val items = HostMediaExtractor.extract(rawContent)
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ private val CONTEXT_REF_RE =
|
|||||||
"""@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)""",
|
"""@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val OUT_OF_BAND_START_RE =
|
||||||
|
Regex("""\[OUT-OF-BAND USER MESSAGE[^\n\]]*\]\s*""", RegexOption.IGNORE_CASE)
|
||||||
|
private val OUT_OF_BAND_END_RE =
|
||||||
|
Regex("""\s*\[/OUT-OF-BAND USER MESSAGE\]""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a gateway-generated `[OUT-OF-BAND USER MESSAGE ...]` wrapper from
|
||||||
|
* mid-turn steering messages while preserving the user-authored prompt.
|
||||||
|
*/
|
||||||
|
internal fun stripGatewaySteerWrapper(content: String): String {
|
||||||
|
val trimmed = content.trimStart()
|
||||||
|
val startMatch = OUT_OF_BAND_START_RE.find(trimmed) ?: return content
|
||||||
|
if (startMatch.range.first != 0) return content
|
||||||
|
val afterStart = trimmed.substring(startMatch.range.last + 1)
|
||||||
|
val endMatch = OUT_OF_BAND_END_RE.find(afterStart)
|
||||||
|
val unwrapped =
|
||||||
|
if (endMatch != null) {
|
||||||
|
afterStart.substring(0, endMatch.range.first)
|
||||||
|
} else {
|
||||||
|
afterStart
|
||||||
|
}
|
||||||
|
return unwrapped.trim()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a gateway-generated `--- Attached Context ---` suffix while
|
* Remove a gateway-generated `--- Attached Context ---` suffix while
|
||||||
* preserving the user-authored portion of the message.
|
* preserving the user-authored portion of the message.
|
||||||
@@ -231,7 +255,7 @@ internal fun matchTranscriptMessages(
|
|||||||
* run_agent). Blank lines left behind are dropped too.
|
* run_agent). Blank lines left behind are dropped too.
|
||||||
*/
|
*/
|
||||||
internal fun stripAttachmentRefLines(content: String): String =
|
internal fun stripAttachmentRefLines(content: String): String =
|
||||||
stripGatewayAttachedContext(content)
|
stripGatewayAttachedContext(stripGatewaySteerWrapper(content))
|
||||||
.lines()
|
.lines()
|
||||||
.map { it.trim() }
|
.map { it.trim() }
|
||||||
.filterNot { line ->
|
.filterNot { line ->
|
||||||
@@ -276,7 +300,11 @@ internal fun dedupeCachedMessages(
|
|||||||
}.toMap()
|
}.toMap()
|
||||||
return unique.filterNot { it.id in echoes }.map { message ->
|
return unique.filterNot { it.id in echoes }.map { message ->
|
||||||
aliases[message.id]?.let {
|
aliases[message.id]?.let {
|
||||||
message.copy(restId = it.canonicalRestId, completionId = message.completionId ?: it.completionId)
|
message.copy(
|
||||||
|
restId = it.canonicalRestId,
|
||||||
|
completionId = message.completionId ?: it.completionId,
|
||||||
|
displayKind = message.displayKind ?: it.displayKind,
|
||||||
|
)
|
||||||
} ?: message
|
} ?: message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,12 +334,18 @@ internal fun mergeCachedTranscriptPage(
|
|||||||
id = match.id,
|
id = match.id,
|
||||||
restId = message.canonicalRestId ?: match.canonicalRestId,
|
restId = message.canonicalRestId ?: match.canonicalRestId,
|
||||||
completionId = match.completionId ?: message.completionId,
|
completionId = match.completionId ?: message.completionId,
|
||||||
|
displayKind = message.displayKind ?: match.displayKind,
|
||||||
)
|
)
|
||||||
}.toMap()
|
}.toMap()
|
||||||
|
val resolvedOrders =
|
||||||
|
incoming.indices
|
||||||
|
.mapNotNull { index ->
|
||||||
|
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
||||||
|
}.toMap()
|
||||||
return (
|
return (
|
||||||
current.map { replacements[it.id] ?: it } +
|
current.map { replacements[it.id] ?: it } +
|
||||||
incoming.filterIndexed { index, _ -> matches[index] == null }
|
incoming.filterIndexed { index, _ -> matches[index] == null }
|
||||||
).dedupeById().inTranscriptOrder().reconcileReasoningRows()
|
).dedupeById().inTranscriptOrder(current, resolvedOrders).reconcileReasoningRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge one page with the current snapshot without consuming repeated results more than once. */
|
/** Merge one page with the current snapshot without consuming repeated results more than once. */
|
||||||
@@ -333,6 +367,7 @@ internal fun mergeTranscriptWithLive(
|
|||||||
match?.role == MessageRole.USER -> {
|
match?.role == MessageRole.USER -> {
|
||||||
match.copy(
|
match.copy(
|
||||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||||
|
displayKind = message.displayKind ?: match.displayKind,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,18 +403,67 @@ internal fun mergeTranscriptWithLive(
|
|||||||
// copy is also present. Fold that now-confirmed echo without changing the live key.
|
// copy is also present. Fold that now-confirmed echo without changing the live key.
|
||||||
// The page has already consumed its content matches; consuming another would
|
// The page has already consumed its content matches; consuming another would
|
||||||
// collapse a separate repeated occurrence. Only confirmed identities can fold here.
|
// collapse a separate repeated occurrence. Only confirmed identities can fold here.
|
||||||
return dedupeCachedMessages(transcript.inTranscriptOrder(), confirmedOnly = true).reconcileReasoningRows()
|
val resolvedOrders =
|
||||||
|
incoming.indices
|
||||||
|
.mapNotNull { index ->
|
||||||
|
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
||||||
|
}.toMap()
|
||||||
|
return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true)
|
||||||
|
.reconcileReasoningRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Numeric canonical history first, then unconfirmed local insertion order; clocks never sort a transcript. */
|
/** Keep server order; place local notices after their last preceding confirmed message, not at the transcript tail. */
|
||||||
private fun List<ChatMessage>.inTranscriptOrder(): List<ChatMessage> =
|
private fun List<ChatMessage>.inTranscriptOrder(
|
||||||
sortedWith(
|
previous: List<ChatMessage>,
|
||||||
compareBy<ChatMessage> { it.canonicalOrder == null }
|
resolvedOrders: Map<String, Long>,
|
||||||
.thenBy { it.canonicalOrder ?: it.localOrder ?: Long.MAX_VALUE },
|
): List<ChatMessage> {
|
||||||
)
|
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
||||||
|
var precedingCanonical: Long? = null
|
||||||
|
var hasPendingPredecessor = false
|
||||||
|
var pendingLocalOrder: Long? = null
|
||||||
|
val noticeAnchors = mutableMapOf<String, Long>()
|
||||||
|
val pendingOrderByNotice = mutableMapOf<String, Long>()
|
||||||
|
previous.forEach { message ->
|
||||||
|
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||||
|
if (order != null) {
|
||||||
|
precedingCanonical = order
|
||||||
|
hasPendingPredecessor = false
|
||||||
|
pendingLocalOrder = null
|
||||||
|
} else if (message.role != MessageRole.SYSTEM) {
|
||||||
|
hasPendingPredecessor = true
|
||||||
|
pendingLocalOrder = message.localOrder
|
||||||
|
} else {
|
||||||
|
noticeAnchors[message.id] =
|
||||||
|
if (hasPendingPredecessor) {
|
||||||
|
Long.MAX_VALUE
|
||||||
|
} else {
|
||||||
|
precedingCanonical?.takeIf { it >= 0L }
|
||||||
|
?: latestCanonical
|
||||||
|
}
|
||||||
|
if (hasPendingPredecessor) pendingOrderByNotice[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
|
||||||
|
return withIndex()
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<IndexedValue<ChatMessage>> {
|
||||||
|
it.value.canonicalOrder ?: noticeAnchors[it.value.id] ?: Long.MAX_VALUE
|
||||||
|
}.thenBy { if (noticeAnchors[it.value.id]?.let { anchor -> anchor != Long.MAX_VALUE } == true) 1 else 0 }
|
||||||
|
.thenBy { it.value.localOrder ?: pendingOrderByNotice[it.value.id] ?: Long.MAX_VALUE }
|
||||||
|
.thenBy { previousIndices[it.value.id] ?: it.index },
|
||||||
|
).map { it.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ChatMessage.isSessionStartMarker(): Boolean =
|
||||||
|
role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched")
|
||||||
|
|
||||||
private val ChatMessage.canonicalOrder: Long?
|
private val ChatMessage.canonicalOrder: Long?
|
||||||
get() = if (localOrder != null && restId == null) null else canonicalRestId?.substringAfterLast('-')?.toLongOrNull()
|
get() =
|
||||||
|
when {
|
||||||
|
isSessionStartMarker() -> -1L
|
||||||
|
localOrder != null && restId == null -> null
|
||||||
|
else -> canonicalRestId?.substringAfterLast('-')?.toLongOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
/** Retain the canonical trace once, without copying it across a user/system/tool boundary. */
|
/** Retain the canonical trace once, without copying it across a user/system/tool boundary. */
|
||||||
private fun List<ChatMessage>.reconcileReasoningRows(): List<ChatMessage> =
|
private fun List<ChatMessage>.reconcileReasoningRows(): List<ChatMessage> =
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.provider.Settings
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
@@ -17,6 +18,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.m57.hermescontrol.data.update.AppUpdateCache
|
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||||
|
import com.m57.hermescontrol.data.update.releaseTag
|
||||||
import com.m57.hermescontrol.ui.common.AppUpdateDialog
|
import com.m57.hermescontrol.ui.common.AppUpdateDialog
|
||||||
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
|
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
|
||||||
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
|
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
|
||||||
@@ -32,14 +34,17 @@ fun ChatAppUpdateSection() {
|
|||||||
AppUpdateViewModel(app)
|
AppUpdateViewModel(app)
|
||||||
}
|
}
|
||||||
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
|
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val currentAppUpdateState by rememberUpdatedState(appUpdateState)
|
||||||
|
|
||||||
val updateLifecycleOwner = LocalLifecycleOwner.current
|
val updateLifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(updateLifecycleOwner) {
|
DisposableEffect(updateLifecycleOwner) {
|
||||||
val observer =
|
val observer =
|
||||||
LifecycleEventObserver { _, event ->
|
LifecycleEventObserver { _, event ->
|
||||||
if (event == Lifecycle.Event.ON_RESUME) {
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
|
if (currentAppUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
|
||||||
appUpdateViewModel.resumeInstallAfterPermission()
|
appUpdateViewModel.resumeInstallAfterPermission()
|
||||||
|
} else {
|
||||||
|
appUpdateViewModel.reconcileInstallerReturn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,15 +54,13 @@ fun ChatAppUpdateSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
|
if (UpdateNoticeManager.enabled) {
|
||||||
val noticeTag =
|
val noticeTag = UpdateNoticeManager.noticeTag()
|
||||||
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
|
||||||
?: UpdateNoticeManager.noticeTag()
|
|
||||||
if (noticeTag != null) {
|
if (noticeTag != null) {
|
||||||
UpdateNoticeBanner(
|
UpdateNoticeBanner(
|
||||||
latestTag = noticeTag,
|
latestTag = noticeTag,
|
||||||
onUpdate = { AppUpdateCache.showDialog() },
|
onUpdate = { AppUpdateCache.showDialog() },
|
||||||
onDismiss = { AppUpdateCache.dismiss() },
|
onDismiss = { AppUpdateCache.dismiss(noticeTag) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,12 +76,17 @@ fun ChatAppUpdateSection() {
|
|||||||
AppUpdateDialog(
|
AppUpdateDialog(
|
||||||
state = dialogState,
|
state = dialogState,
|
||||||
onDismiss = { AppUpdateCache.hideDialog() },
|
onDismiss = { AppUpdateCache.hideDialog() },
|
||||||
|
onLater = {
|
||||||
|
dialogState.releaseTag()?.let(AppUpdateCache::dismiss)
|
||||||
|
AppUpdateCache.hideDialog()
|
||||||
|
},
|
||||||
onStartUpdate = { appUpdateViewModel.startUpdate() },
|
onStartUpdate = { appUpdateViewModel.startUpdate() },
|
||||||
onCancelDownload = { appUpdateViewModel.cancelDownload() },
|
onCancelDownload = { appUpdateViewModel.cancelDownload() },
|
||||||
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
launchUnknownAppSourcesSettings(context)
|
launchUnknownAppSourcesSettings(context)
|
||||||
},
|
},
|
||||||
|
onCheckUpdate = { appUpdateViewModel.checkForUpdate() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ private fun ChatMessage.isSyntheticSystemRow(): Boolean =
|
|||||||
displayKind == null &&
|
displayKind == null &&
|
||||||
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
||||||
|
|
||||||
|
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind != "steer"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a flat message list into turns for the full-bleed renderer.
|
* Split a flat message list into turns for the full-bleed renderer.
|
||||||
*
|
*
|
||||||
@@ -80,11 +82,12 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
|||||||
// Timeline markers (display_kind) ride as role=user rows but are
|
// Timeline markers (display_kind) ride as role=user rows but are
|
||||||
// NOT user turns — group them as system-style timeline entries so
|
// NOT user turns — group them as system-style timeline entries so
|
||||||
// they render as centered chips, not fake user bubbles (issue #904).
|
// they render as centered chips, not fake user bubbles (issue #904).
|
||||||
|
// Steer messages (display_kind == "steer") are genuine user turns.
|
||||||
// The backend's max-iterations nudge is a role=user row with NO
|
// The backend's max-iterations nudge is a role=user row with NO
|
||||||
// display_kind (it's stripped on persistence); detect it by its
|
// display_kind (it's stripped on persistence); detect it by its
|
||||||
// stable content prefix and route it as a system event too, tagging
|
// stable content prefix and route it as a system event too, tagging
|
||||||
// it so the timeline chip can name it.
|
// it so the timeline chip can name it.
|
||||||
message.displayKind != null -> {
|
message.isTimelineMarker() -> {
|
||||||
agentEntries += AgentEntry.SystemEvent(message)
|
agentEntries += AgentEntry.SystemEvent(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ import com.m57.hermescontrol.data.update.releaseTag
|
|||||||
fun AppUpdateDialog(
|
fun AppUpdateDialog(
|
||||||
state: AppUpdateState,
|
state: AppUpdateState,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onLater: () -> Unit = onDismiss,
|
||||||
onStartUpdate: () -> Unit,
|
onStartUpdate: () -> Unit,
|
||||||
onCancelDownload: () -> Unit,
|
onCancelDownload: () -> Unit,
|
||||||
onNeverAskAgain: () -> Unit,
|
onNeverAskAgain: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCheckUpdate: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val available = state as? AppUpdateState.UpdateAvailable
|
val available = state as? AppUpdateState.UpdateAvailable
|
||||||
var cachedAvailable by remember { mutableStateOf(available) }
|
var cachedAvailable by remember { mutableStateOf(available) }
|
||||||
@@ -160,6 +162,26 @@ fun AppUpdateDialog(
|
|||||||
|
|
||||||
// Progress / Action States
|
// Progress / Action States
|
||||||
when (state) {
|
when (state) {
|
||||||
|
AppUpdateState.Checking -> {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_about_update_checking),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppUpdateState.UpToDate -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_about_update_uptodate, state.latestTag),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is AppUpdateState.Downloading -> {
|
is AppUpdateState.Downloading -> {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -260,7 +282,7 @@ fun AppUpdateDialog(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onStartUpdate,
|
onClick = if (state.isCheckError) onCheckUpdate else onStartUpdate,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.settings_about_update_retry))
|
Text(stringResource(R.string.settings_about_update_retry))
|
||||||
@@ -268,8 +290,7 @@ fun AppUpdateDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
is AppUpdateState.UpdateAvailable -> {
|
||||||
// UpdateAvailable or Idle
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onStartUpdate,
|
onClick = onStartUpdate,
|
||||||
@@ -291,7 +312,7 @@ fun AppUpdateDialog(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onLater) {
|
||||||
Text(stringResource(R.string.update_dialog_action_later))
|
Text(stringResource(R.string.update_dialog_action_later))
|
||||||
}
|
}
|
||||||
TextButton(onClick = onNeverAskAgain) {
|
TextButton(onClick = onNeverAskAgain) {
|
||||||
@@ -303,6 +324,12 @@ fun AppUpdateDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppUpdateState.Idle -> {
|
||||||
|
Button(onClick = onCheckUpdate, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(stringResource(R.string.settings_about_update_check))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ import com.m57.hermescontrol.data.local.AuthManager
|
|||||||
import com.m57.hermescontrol.data.update.AppUpdateCache
|
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
import com.m57.hermescontrol.data.update.isNewerVersion
|
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||||
import com.m57.hermescontrol.data.update.isReleaseCandidateVersion
|
import com.m57.hermescontrol.data.update.isReleaseCandidateVersion
|
||||||
import com.m57.hermescontrol.data.update.releaseTag
|
import com.m57.hermescontrol.data.update.releaseTag
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ensureActive
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives the in-app self-update flow (issue #867): check the GitHub
|
* Drives the in-app self-update flow (issue #867): check the GitHub
|
||||||
@@ -31,9 +31,8 @@ import java.io.IOException
|
|||||||
* installer. The installer launch uses the application context
|
* installer. The installer launch uses the application context
|
||||||
* (FLAG_ACTIVITY_NEW_TASK), so no Activity is required.
|
* (FLAG_ACTIVITY_NEW_TASK), so no Activity is required.
|
||||||
*
|
*
|
||||||
* A silent check runs once per installed version (guarded by the
|
* The ViewModel adopts the process-shared launch/foreground check result, while
|
||||||
* `updateCheckDoneForVersion` app pref) — that's what puts the "Update
|
* manual checks, downloads, and system-installer handoff stay screen-owned.
|
||||||
* available" badge on the About row without the user tapping anything.
|
|
||||||
*/
|
*/
|
||||||
class AppUpdateViewModel(
|
class AppUpdateViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
@@ -51,16 +50,11 @@ class AppUpdateViewModel(
|
|||||||
/** Whether the RC update channel is enabled (issue: RC update toggle). */
|
/** Whether the RC update channel is enabled (issue: RC update toggle). */
|
||||||
val checkReleaseCandidateUpdates: StateFlow<Boolean> = _checkReleaseCandidateUpdates.asStateFlow()
|
val checkReleaseCandidateUpdates: StateFlow<Boolean> = _checkReleaseCandidateUpdates.asStateFlow()
|
||||||
|
|
||||||
private var checkJob: kotlinx.coroutines.Job? = null
|
|
||||||
private var downloadJob: kotlinx.coroutines.Job? = null
|
private var downloadJob: kotlinx.coroutines.Job? = null
|
||||||
private var lastAvailable: AppUpdateState.UpdateAvailable? = null
|
private var lastAvailable: AppUpdateState.UpdateAvailable? = null
|
||||||
|
private var downloadGeneration = 0
|
||||||
/**
|
private var downloadPartialFile: File? = null
|
||||||
* Bumped by every check. The blocking OkHttp call underneath cannot be
|
private var installerReturnPending = false
|
||||||
* interrupted, so a cancelled check may still be running; results are only
|
|
||||||
* published when this token is unchanged (and the channel still matches).
|
|
||||||
*/
|
|
||||||
private var checkGeneration = 0
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val cached = AppUpdateCache.state.value
|
val cached = AppUpdateCache.state.value
|
||||||
@@ -70,13 +64,25 @@ class AppUpdateViewModel(
|
|||||||
!_checkReleaseCandidateUpdates.value &&
|
!_checkReleaseCandidateUpdates.value &&
|
||||||
cached.releaseTag()?.let(::isReleaseCandidateVersion) == true
|
cached.releaseTag()?.let(::isReleaseCandidateVersion) == true
|
||||||
if (blocked) AppUpdateCache.reset()
|
if (blocked) AppUpdateCache.reset()
|
||||||
|
viewModelScope.launch {
|
||||||
|
AppUpdateCache.state.collect { sharedState ->
|
||||||
|
if (_state.value !is AppUpdateState.Downloading &&
|
||||||
|
_state.value !is AppUpdateState.Installing &&
|
||||||
|
_state.value !is AppUpdateState.NeedsUnknownSourcesPermission
|
||||||
|
) {
|
||||||
|
_state.value = sharedState
|
||||||
|
if (sharedState is AppUpdateState.UpdateAvailable) lastAvailable = sharedState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (cached is AppUpdateState.UpdateAvailable && !blocked) {
|
if (cached is AppUpdateState.UpdateAvailable && !blocked) {
|
||||||
lastAvailable = cached
|
lastAvailable = cached
|
||||||
_state.value = cached
|
_state.value = cached
|
||||||
} else if (cached is AppUpdateState.UpToDate && !blocked) {
|
} else if (cached is AppUpdateState.UpToDate && !blocked) {
|
||||||
_state.value = cached
|
_state.value = cached
|
||||||
} else {
|
} else {
|
||||||
checkForUpdate()
|
_state.value = cached
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, ioDispatcher)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,91 +100,19 @@ class AppUpdateViewModel(
|
|||||||
*/
|
*/
|
||||||
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
|
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
|
||||||
if (_checkReleaseCandidateUpdates.value == enabled) return
|
if (_checkReleaseCandidateUpdates.value == enabled) return
|
||||||
AuthManager.setCheckReleaseCandidateUpdates(enabled)
|
UpdateNoticeManager.channelChanged(enabled)
|
||||||
_checkReleaseCandidateUpdates.value = enabled
|
_checkReleaseCandidateUpdates.value = enabled
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
if (lastAvailable?.latestTag?.let(::isReleaseCandidateVersion) == true) cancelDownload()
|
||||||
lastAvailable = null
|
lastAvailable = null
|
||||||
AppUpdateCache.reset()
|
AppUpdateCache.reset()
|
||||||
}
|
}
|
||||||
// A check already in flight captured the previous channel — cancel it and
|
|
||||||
// bump the generation so a late result can never be published.
|
|
||||||
checkJob?.cancel()
|
|
||||||
runCheck()
|
runCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runCheck() {
|
private fun runCheck() {
|
||||||
val generation = ++checkGeneration
|
UpdateNoticeManager.checkNow(checker, currentVersion, ioDispatcher)
|
||||||
val includeReleaseCandidates = _checkReleaseCandidateUpdates.value
|
|
||||||
_state.value = AppUpdateState.Checking
|
|
||||||
checkJob =
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
|
||||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
|
||||||
val result =
|
|
||||||
try {
|
|
||||||
checker.fetchLatestRelease(includeReleaseCandidates)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
// Cancellation is control flow — it must never be reported
|
|
||||||
// as a failed check.
|
|
||||||
throw e
|
|
||||||
} catch (e: IOException) {
|
|
||||||
if (isCurrentCheck(generation, includeReleaseCandidates)) {
|
|
||||||
_state.value = AppUpdateState.Error(NETWORK_ERROR)
|
|
||||||
}
|
}
|
||||||
return@launch
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (isCurrentCheck(generation, includeReleaseCandidates)) {
|
|
||||||
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// The OkHttp call underneath blocks and cannot be interrupted, so a
|
|
||||||
// newer check — or a channel switch — may have happened while it ran.
|
|
||||||
ensureActive()
|
|
||||||
if (!isCurrentCheck(generation, includeReleaseCandidates)) return@launch
|
|
||||||
|
|
||||||
val info =
|
|
||||||
result ?: run {
|
|
||||||
_state.value = AppUpdateState.Error(NO_RELEASE_ERROR)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val apk =
|
|
||||||
info.apkAsset ?: run {
|
|
||||||
_state.value = AppUpdateState.Error(NO_APK_ERROR)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val state =
|
|
||||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
|
||||||
AppUpdateState.UpdateAvailable(
|
|
||||||
latestTag = info.tagName,
|
|
||||||
apkUrl = apk.browserDownloadUrl,
|
|
||||||
sizeBytes = apk.size,
|
|
||||||
releaseNotes = info.body,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
|
||||||
}
|
|
||||||
// Fail closed: a release-candidate result is never published once
|
|
||||||
// the RC channel is off, however it reached this point.
|
|
||||||
if (!isAdmissibleOnCurrentChannel(state.releaseTag())) return@launch
|
|
||||||
if (state is AppUpdateState.UpdateAvailable) lastAvailable = state
|
|
||||||
_state.value = state
|
|
||||||
// Keep the launch notice (issue #890) in sync with manual checks.
|
|
||||||
AppUpdateCache.update(state)
|
|
||||||
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when [generation] is still the newest check *and* the channel that
|
|
||||||
* check was started for is still the selected one.
|
|
||||||
*/
|
|
||||||
private fun isCurrentCheck(
|
|
||||||
generation: Int,
|
|
||||||
includeReleaseCandidates: Boolean,
|
|
||||||
): Boolean = generation == checkGeneration && includeReleaseCandidates == _checkReleaseCandidateUpdates.value
|
|
||||||
|
|
||||||
/** A release-candidate tag may only be used while the RC channel is on. */
|
/** A release-candidate tag may only be used while the RC channel is on. */
|
||||||
private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean =
|
private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean =
|
||||||
@@ -208,36 +142,70 @@ class AppUpdateViewModel(
|
|||||||
|
|
||||||
/** Download the release APK and launch the system installer. */
|
/** Download the release APK and launch the system installer. */
|
||||||
fun startUpdate() {
|
fun startUpdate() {
|
||||||
|
if (_state.value is AppUpdateState.Downloading || _state.value is AppUpdateState.Installing) return
|
||||||
val available = updateAvailableForInstall() ?: return
|
val available = updateAvailableForInstall() ?: return
|
||||||
lastAvailable = available
|
lastAvailable = available
|
||||||
if (!canRequestInstalls()) {
|
if (!canRequestInstalls()) {
|
||||||
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
|
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
|
val dest = updateApkFile(available.latestTag)
|
||||||
|
val generation = ++downloadGeneration
|
||||||
|
val partial = File(dest.parentFile, "${dest.name}.${UUID.randomUUID()}.$generation.part")
|
||||||
|
downloadPartialFile = partial
|
||||||
|
try {
|
||||||
|
partial.delete()
|
||||||
_state.value = AppUpdateState.Downloading(0f)
|
_state.value = AppUpdateState.Downloading(0f)
|
||||||
downloadJob?.cancel()
|
downloadJob?.cancel()
|
||||||
downloadJob =
|
downloadJob =
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
try {
|
||||||
val downloaded =
|
val downloaded =
|
||||||
checker.downloadApk(available.apkUrl, dest) { progress ->
|
checker.downloadApk(available.apkUrl, partial) { progress ->
|
||||||
|
if (generation ==
|
||||||
|
downloadGeneration
|
||||||
|
) {
|
||||||
_state.value = AppUpdateState.Downloading(progress)
|
_state.value = AppUpdateState.Downloading(progress)
|
||||||
}
|
}
|
||||||
if (!downloaded) {
|
|
||||||
if (downloadJob?.isCancelled != true) {
|
|
||||||
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
|
|
||||||
}
|
}
|
||||||
|
if (generation != downloadGeneration) return@launch
|
||||||
|
if (!downloaded || !partial.exists() || partial.length() == 0L) {
|
||||||
|
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if ((dest.exists() && !dest.delete()) || !partial.renameTo(dest)) {
|
||||||
|
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
_state.value = AppUpdateState.Installing(available.latestTag)
|
_state.value = AppUpdateState.Installing(available.latestTag)
|
||||||
launchInstaller(dest)
|
launchInstaller(dest)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (generation == downloadGeneration) _state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
|
||||||
|
} finally {
|
||||||
|
if (generation == downloadGeneration) {
|
||||||
|
partial.delete()
|
||||||
|
downloadPartialFile = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (generation == downloadGeneration) {
|
||||||
|
partial.delete()
|
||||||
|
downloadPartialFile = null
|
||||||
|
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel in-flight download and return to UpdateAvailable. */
|
/** Cancel in-flight download and return to UpdateAvailable. */
|
||||||
fun cancelDownload() {
|
fun cancelDownload() {
|
||||||
|
downloadGeneration++
|
||||||
downloadJob?.cancel()
|
downloadJob?.cancel()
|
||||||
downloadJob = null
|
downloadJob = null
|
||||||
|
downloadPartialFile?.delete()
|
||||||
|
downloadPartialFile = null
|
||||||
val available =
|
val available =
|
||||||
lastAvailable
|
lastAvailable
|
||||||
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
|
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
|
||||||
@@ -257,16 +225,15 @@ class AppUpdateViewModel(
|
|||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
AuthManager.setDismissedUpdateTag(tag)
|
AuthManager.setDismissedUpdateTag(tag)
|
||||||
}
|
}
|
||||||
AppUpdateCache.dismiss()
|
|
||||||
AppUpdateCache.hideDialog()
|
AppUpdateCache.hideDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resume install when returning from Unknown Sources permission screen. */
|
/** Resume install when returning from Unknown Sources permission screen. */
|
||||||
fun resumeInstallAfterPermission() {
|
fun resumeInstallAfterPermission() {
|
||||||
if (canRequestInstalls()) {
|
if (canRequestInstalls()) {
|
||||||
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
|
|
||||||
val available = updateAvailableForInstall()
|
val available = updateAvailableForInstall()
|
||||||
if (dest.exists() && dest.length() > 0 && available != null) {
|
val dest = available?.let { updateApkFile(it.latestTag) }
|
||||||
|
if (dest != null && dest.exists() && dest.length() > 0L) {
|
||||||
_state.value = AppUpdateState.Installing(available.latestTag)
|
_state.value = AppUpdateState.Installing(available.latestTag)
|
||||||
launchInstaller(dest)
|
launchInstaller(dest)
|
||||||
} else {
|
} else {
|
||||||
@@ -275,6 +242,18 @@ class AppUpdateViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returning from the external installer is not proof that installation succeeded. */
|
||||||
|
fun reconcileInstallerReturn() {
|
||||||
|
if (_state.value !is AppUpdateState.Installing || !installerReturnPending) return
|
||||||
|
installerReturnPending = false
|
||||||
|
_state.value = lastAvailable ?: AppUpdateCache.state.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateApkFile(tag: String): File {
|
||||||
|
val safeTag = tag.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||||
|
return File(getApplication<Application>().cacheDir, "hermes-update-$safeTag.apk")
|
||||||
|
}
|
||||||
|
|
||||||
private fun canRequestInstalls(): Boolean =
|
private fun canRequestInstalls(): Boolean =
|
||||||
try {
|
try {
|
||||||
getApplication<Application>().packageManager.canRequestPackageInstalls()
|
getApplication<Application>().packageManager.canRequestPackageInstalls()
|
||||||
@@ -292,18 +271,14 @@ class AppUpdateViewModel(
|
|||||||
apkFile,
|
apkFile,
|
||||||
)
|
)
|
||||||
context.startActivity(installIntentFactory(uri))
|
context.startActivity(installIntentFactory(uri))
|
||||||
|
installerReturnPending = true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
installerReturnPending = false
|
||||||
_state.value = AppUpdateState.Error(INSTALLER_ERROR)
|
_state.value = AppUpdateState.Error(INSTALLER_ERROR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val APK_FILE_NAME = "hermes-update.apk"
|
|
||||||
|
|
||||||
val NETWORK_ERROR = "Network error — check your connection"
|
|
||||||
val GENERIC_CHECK_ERROR = "Couldn't check for updates"
|
|
||||||
val NO_RELEASE_ERROR = "No release found yet"
|
|
||||||
val NO_APK_ERROR = "Release has no APK asset"
|
|
||||||
val DOWNLOAD_ERROR = "Download failed — tap to retry"
|
val DOWNLOAD_ERROR = "Download failed — tap to retry"
|
||||||
val INSTALLER_ERROR = "Couldn't open the installer"
|
val INSTALLER_ERROR = "Couldn't open the installer"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ internal fun SettingsAboutPage(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val currentUpdateState by androidx.compose.runtime.rememberUpdatedState(updateState)
|
||||||
val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle()
|
val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||||
@@ -270,8 +271,12 @@ internal fun SettingsAboutPage(
|
|||||||
val observer =
|
val observer =
|
||||||
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
|
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
|
||||||
if (updateState is com.m57.hermescontrol.data.update.AppUpdateState.NeedsUnknownSourcesPermission) {
|
if (currentUpdateState is
|
||||||
|
com.m57.hermescontrol.data.update.AppUpdateState.NeedsUnknownSourcesPermission
|
||||||
|
) {
|
||||||
viewModel.resumeInstallAfterPermission()
|
viewModel.resumeInstallAfterPermission()
|
||||||
|
} else {
|
||||||
|
viewModel.reconcileInstallerReturn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,4 +201,24 @@ class ChatMessageMapperTest {
|
|||||||
|
|
||||||
assertNull(roundTripped.displayKind)
|
assertNull(roundTripped.displayKind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionStartMarkerDoesNotAcquireLocalOrder() {
|
||||||
|
val entity =
|
||||||
|
ChatMessageEntity(
|
||||||
|
id = "uuid-session-created",
|
||||||
|
sessionId = "s",
|
||||||
|
role = "SYSTEM",
|
||||||
|
content = "Session created",
|
||||||
|
timestamp = 1000L,
|
||||||
|
sortGroup = 1,
|
||||||
|
sortOrder = 5L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ui = entity.toUiModel()
|
||||||
|
|
||||||
|
assertNull(ui.localOrder)
|
||||||
|
assertEquals(MessageRole.SYSTEM, ui.role)
|
||||||
|
assertEquals("Session created", ui.content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
package com.m57.hermescontrol.data.update
|
package com.m57.hermescontrol.data.update
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.SocketPolicy
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint contract for the opt-in release-candidate channel: the stable path
|
* Endpoint contract for the opt-in release-candidate channel: the stable path
|
||||||
@@ -38,8 +45,11 @@ class AppUpdateCheckerNetworkTest {
|
|||||||
private fun enqueue(
|
private fun enqueue(
|
||||||
body: String,
|
body: String,
|
||||||
code: Int = 200,
|
code: Int = 200,
|
||||||
|
headers: Map<String, String> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
server.enqueue(MockResponse().setResponseCode(code).setBody(body))
|
val response = MockResponse().setResponseCode(code).setBody(body)
|
||||||
|
headers.forEach(response::addHeader)
|
||||||
|
server.enqueue(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -56,26 +66,62 @@ class AppUpdateCheckerNetworkTest {
|
|||||||
@Test
|
@Test
|
||||||
fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() =
|
fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() =
|
||||||
runTest {
|
runTest {
|
||||||
|
enqueue(STABLE_BODY)
|
||||||
enqueue(RELEASES_BODY)
|
enqueue(RELEASES_BODY)
|
||||||
|
|
||||||
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
|
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
|
||||||
|
|
||||||
assertEquals("v1.25.0-rc.3", info?.tagName)
|
assertEquals("v1.25.0-rc.3", info?.tagName)
|
||||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=20", server.takeRequest().path)
|
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
|
||||||
|
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBeta() =
|
fun fetchLatestRelease_stableFallsBackToReleasesWhenLatestIsNotUsable() =
|
||||||
runTest {
|
runTest {
|
||||||
|
enqueue("{}")
|
||||||
|
enqueue(RELEASES_BODY)
|
||||||
|
|
||||||
|
val info = checker().fetchLatestRelease()
|
||||||
|
|
||||||
|
assertEquals("v1.24.2", info?.tagName)
|
||||||
|
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
|
||||||
|
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchLatestRelease_rcScanFollowsGithubPagination() =
|
||||||
|
runTest {
|
||||||
|
enqueue(STABLE_BODY)
|
||||||
|
val nextPage = server.url("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2")
|
||||||
|
enqueue(RELEASES_BODY, headers = mapOf("Link" to "<$nextPage>; rel=\"next\""))
|
||||||
|
enqueue(
|
||||||
|
"""[{"tag_name":"v1.26.0-rc.1","prerelease":true,"assets":[{"name":"release.apk","size":5,"browser_download_url":"https://example.com/new.apk"}]}]""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
|
||||||
|
|
||||||
|
assertEquals("v1.26.0-rc.1", info?.tagName)
|
||||||
|
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
|
||||||
|
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
|
||||||
|
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2", server.takeRequest().path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBetaButKeepStable() =
|
||||||
|
runTest {
|
||||||
|
enqueue(STABLE_BODY)
|
||||||
enqueue(ALPHA_BETA_BODY)
|
enqueue(ALPHA_BETA_BODY)
|
||||||
|
|
||||||
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
|
assertEquals("v1.24.2", checker().fetchLatestRelease(includeReleaseCandidates = true)?.tagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun fetchLatestRelease_nonSuccessYieldsNull() =
|
fun fetchLatestRelease_nonSuccessYieldsNull() =
|
||||||
runTest {
|
runTest {
|
||||||
// One queued response per request: stable path, then RC path.
|
// Each channel checks the stable endpoint and falls back to the list on 404.
|
||||||
|
enqueue("", code = 404)
|
||||||
|
enqueue("", code = 404)
|
||||||
enqueue("", code = 404)
|
enqueue("", code = 404)
|
||||||
enqueue("", code = 404)
|
enqueue("", code = 404)
|
||||||
|
|
||||||
@@ -83,6 +129,35 @@ class AppUpdateCheckerNetworkTest {
|
|||||||
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
|
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloadApk_cancellingCoroutineCancelsNetworkAndDeletesPartialFile() =
|
||||||
|
runTest {
|
||||||
|
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))
|
||||||
|
val destination = File.createTempFile("hermes-update", ".apk")
|
||||||
|
destination.writeText("partial")
|
||||||
|
val download =
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
checker().downloadApk(server.url("/slow.apk").toString(), destination) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("/slow.apk", server.takeRequest(5, TimeUnit.SECONDS)?.path)
|
||||||
|
download.cancelAndJoin()
|
||||||
|
|
||||||
|
assertFalse(destination.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloadApk_invalidUrlReturnsFailureAndDeletesDestination() =
|
||||||
|
runTest {
|
||||||
|
val destination = File.createTempFile("hermes-update", ".apk")
|
||||||
|
destination.writeText("partial")
|
||||||
|
|
||||||
|
val downloaded = checker().downloadApk("file:///tmp/release.apk", destination) {}
|
||||||
|
|
||||||
|
assertFalse(downloaded)
|
||||||
|
assertFalse(destination.exists())
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val STABLE_BODY = """
|
const val STABLE_BODY = """
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ class AppUpdateCheckerTest {
|
|||||||
draft: Boolean = false,
|
draft: Boolean = false,
|
||||||
prerelease: Boolean = false,
|
prerelease: Boolean = false,
|
||||||
withApk: Boolean = true,
|
withApk: Boolean = true,
|
||||||
|
apkUrl: String = "https://example.com/$tag.apk",
|
||||||
): UpdateInfo =
|
): UpdateInfo =
|
||||||
UpdateInfo(
|
UpdateInfo(
|
||||||
tagName = tag,
|
tagName = tag,
|
||||||
@@ -194,7 +195,7 @@ class AppUpdateCheckerTest {
|
|||||||
UpdateInfo.Asset(
|
UpdateInfo.Asset(
|
||||||
name = "hermes-mobile-$tag.apk",
|
name = "hermes-mobile-$tag.apk",
|
||||||
size = 1L,
|
size = 1L,
|
||||||
browserDownloadUrl = "https://example.com/$tag.apk",
|
browserDownloadUrl = apkUrl,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -289,6 +290,55 @@ class AppUpdateCheckerTest {
|
|||||||
assertNull(selectLatestUpdate(listOf(release("v1.25.0", draft = true), release("v1.24.0", withApk = false))))
|
assertNull(selectLatestUpdate(listOf(release("v1.25.0", draft = true), release("v1.24.0", withApk = false))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectLatestUpdate_rejectsPrereleaseTagsMislabelledAsStable() {
|
||||||
|
val releases =
|
||||||
|
listOf(
|
||||||
|
release("v1.30.0-rc.1"),
|
||||||
|
release("v1.29.0-alpha.1"),
|
||||||
|
release("v1.28.0-beta.2"),
|
||||||
|
release("v1.27.0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("v1.27.0", selectLatestUpdate(releases)?.tagName)
|
||||||
|
assertEquals("v1.30.0-rc.1", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectLatestUpdate_ignoresInvalidTagsAndUnusableApkUrls() {
|
||||||
|
val releases =
|
||||||
|
listOf(
|
||||||
|
release("not-a-version"),
|
||||||
|
release("v1.30.0", apkUrl = " "),
|
||||||
|
release("v1.29.0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("v1.29.0", selectLatestUpdate(releases)?.tagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectLatestUpdate_rejectsMalformedAndUnsupportedApkUrlsButAllowsHttp() {
|
||||||
|
val releases =
|
||||||
|
listOf(
|
||||||
|
release("v1.32.0", apkUrl = "https://bad host/release.apk"),
|
||||||
|
release("v1.31.0", apkUrl = "file:///tmp/release.apk"),
|
||||||
|
release("v1.30.0", apkUrl = "http://localhost/release.apk"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("v1.30.0", selectLatestUpdate(releases)?.tagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectLatestUpdate_stableOutranksRcForSameVersion() {
|
||||||
|
val releases =
|
||||||
|
listOf(
|
||||||
|
release("v1.25.0-rc.10", prerelease = true),
|
||||||
|
release("v1.25.0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("v1.25.0", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseReleaseList_readsDraftAndPrereleaseFlags() {
|
fun parseReleaseList_readsDraftAndPrereleaseFlags() {
|
||||||
val json =
|
val json =
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class UpdateNoticeManagerTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
UpdateNoticeManager.resetForTests()
|
||||||
AppUpdateCache.reset()
|
AppUpdateCache.reset()
|
||||||
// Unit tests run against the debug variant where the feature is
|
// Unit tests run against the debug variant where the feature is
|
||||||
// disabled by default — flip the knob on so the behavior is testable.
|
// disabled by default — flip the knob on so the behavior is testable.
|
||||||
@@ -68,6 +69,7 @@ class UpdateNoticeManagerTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
UpdateNoticeManager.resetForTests()
|
||||||
UpdateNoticeManager.enabled = false
|
UpdateNoticeManager.enabled = false
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
Dispatchers.resetMain()
|
Dispatchers.resetMain()
|
||||||
@@ -92,7 +94,7 @@ class UpdateNoticeManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun checkOnLaunch_skipsWhenAlreadyCheckedWithin24Hours() =
|
fun checkOnLaunch_ignoresPersistedDailyCheckTimestampAfterColdStart() =
|
||||||
runTest {
|
runTest {
|
||||||
val now = 1000000000000L
|
val now = 1000000000000L
|
||||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
||||||
@@ -103,9 +105,24 @@ class UpdateNoticeManagerTest {
|
|||||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
|
||||||
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_debouncesRepeatedForegroundChecksAfterSuccess() =
|
||||||
|
runTest {
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease(false) } returns updateInfo()
|
||||||
|
val now = 1_000_000L
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now)
|
||||||
|
advanceUntilIdle()
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now + 1_000L)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -148,9 +165,9 @@ class UpdateNoticeManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun noticeTag_stableReleasePromptsDotRcFromPersistedCache() {
|
fun noticeTag_doesNotUsePersistedTagForStableInstall() {
|
||||||
every { AuthManager.getLastKnownLatestTag() } returns "v1.25"
|
every { AuthManager.getLastKnownLatestTag() } returns "v1.25"
|
||||||
assertEquals("v1.25", UpdateNoticeManager.noticeTag("1.25.rc.1"))
|
assertNull(UpdateNoticeManager.noticeTag("1.25.rc.1"))
|
||||||
assertNull(UpdateNoticeManager.noticeTag("1.25"))
|
assertNull(UpdateNoticeManager.noticeTag("1.25"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,9 +193,11 @@ class UpdateNoticeManagerTest {
|
|||||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
assertEquals(
|
||||||
// Still marked done so a dead network can't spam the API per launch.
|
AppUpdateState.Error("Network error — check your connection", isCheckError = true),
|
||||||
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
AppUpdateCache.state.value,
|
||||||
|
)
|
||||||
|
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -190,8 +209,41 @@ class UpdateNoticeManagerTest {
|
|||||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(AppUpdateState.Error("No release found yet", isCheckError = true), AppUpdateCache.state.value)
|
||||||
|
assertFalse(AppUpdateCache.isDismissed("v1.22.0"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_noReleaseDoesNotPersistSuccessfulCheck() =
|
||||||
|
runTest {
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease(false) } returns null
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
|
||||||
|
verify(exactly = 0) { AuthManager.setLastUpdateCheckTimestamp(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_channelInvalidationDuringFetchPreservesNewChannelState() =
|
||||||
|
runTest {
|
||||||
|
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease(true) } coAnswers {
|
||||||
|
// A channel change invalidates the captured request while it is completing.
|
||||||
|
UpdateNoticeManager.channelChanged(enabled = false)
|
||||||
|
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||||
|
updateInfo(tag = "v1.25.0-rc.3")
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||||
assertFalse(AppUpdateCache.dismissed)
|
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
|
||||||
|
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── noticeTag (dismissed/restart banner return) ─────────────────────
|
// ── noticeTag (dismissed/restart banner return) ─────────────────────
|
||||||
@@ -211,10 +263,10 @@ class UpdateNoticeManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun noticeTag_returnsPersistedNewerTagWhenCacheIdle() {
|
fun noticeTag_doesNotAdvertisePersistedTagWithoutFreshApkMetadata() {
|
||||||
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
|
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
|
||||||
|
|
||||||
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion))
|
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -303,11 +355,11 @@ class UpdateNoticeManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun noticeTag_advertisesPersistedRcTagWhenOptedIn() {
|
fun noticeTag_doesNotUsePersistedRcTagWithoutApkMetadata() {
|
||||||
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
|
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
|
||||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||||
|
|
||||||
assertEquals("v1.25.0-rc.3", UpdateNoticeManager.noticeTag(currentVersion))
|
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -336,10 +388,50 @@ class UpdateNoticeManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
UpdateNoticeManager.channelChanged(enabled = false)
|
||||||
|
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// The late RC result must not reach the cache or the persisted tag.
|
// A genuine manager-mediated opt-out invalidates the in-flight RC result.
|
||||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||||
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
|
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_retriesAfterFailureBackoffInsteadOfPersistingFailureAsDone() =
|
||||||
|
runTest {
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease(false) } throws IOException("offline") andThen updateInfo()
|
||||||
|
val now = 100_000L
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now)
|
||||||
|
advanceUntilIdle()
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now + 1_000L)
|
||||||
|
advanceUntilIdle()
|
||||||
|
UpdateNoticeManager.checkOnLaunch(
|
||||||
|
checker,
|
||||||
|
currentVersion,
|
||||||
|
testDispatcher,
|
||||||
|
now + UpdateNoticeManager.FAILURE_BACKOFF_MS + 1L,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 2) { checker.fetchLatestRelease(false) }
|
||||||
|
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noticeTag_dismissalAppliesOnlyToThatRelease() {
|
||||||
|
AppUpdateCache.update(
|
||||||
|
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/1.apk", 1L),
|
||||||
|
)
|
||||||
|
AppUpdateCache.dismiss("v1.22.0")
|
||||||
|
|
||||||
|
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
|
|
||||||
|
AppUpdateCache.update(
|
||||||
|
AppUpdateState.UpdateAvailable("v1.23.0", "https://example.com/2.apk", 1L),
|
||||||
|
)
|
||||||
|
assertEquals("v1.23.0", UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,28 @@ class ChatListEntriesTest {
|
|||||||
assertEquals(mapOf(6 to 5), toolCallMilestoneIndices(messages))
|
assertEquals(mapOf(6 to 5), toolCallMilestoneIndices(messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun steerMessageResetsToolCounterAsUserTurnBoundary() {
|
||||||
|
val steer =
|
||||||
|
ChatMessage(
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "Stop and run tests",
|
||||||
|
displayKind = "steer",
|
||||||
|
)
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
userMessage(),
|
||||||
|
toolMessage(),
|
||||||
|
toolMessage(),
|
||||||
|
steer,
|
||||||
|
toolMessage(),
|
||||||
|
toolMessage(),
|
||||||
|
toolMessage(),
|
||||||
|
)
|
||||||
|
// Only 3 tool calls after the steer boundary, so no 5-call milestone reached
|
||||||
|
assertTrue(toolCallMilestones(messages).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun syntheticMaxIterationsNudgeDoesNotResetToolCounter() {
|
fun syntheticMaxIterationsNudgeDoesNotResetToolCounter() {
|
||||||
val nudge =
|
val nudge =
|
||||||
|
|||||||
@@ -581,6 +581,208 @@ class ChatPagingMergeTest {
|
|||||||
assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live))
|
assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stripGatewaySteerWrapperRemovesEnvelopesSafely() {
|
||||||
|
val standard =
|
||||||
|
"[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once at this position; " +
|
||||||
|
"not tool output and not a new delivery when replayed from conversation history]\n" +
|
||||||
|
"Please use Python instead.\n" +
|
||||||
|
"[/OUT-OF-BAND USER MESSAGE]"
|
||||||
|
assertEquals("Please use Python instead.", stripGatewaySteerWrapper(standard))
|
||||||
|
|
||||||
|
val noDescription =
|
||||||
|
"[OUT-OF-BAND USER MESSAGE]\n" +
|
||||||
|
"Halt.\n" +
|
||||||
|
"[/OUT-OF-BAND USER MESSAGE]"
|
||||||
|
assertEquals("Halt.", stripGatewaySteerWrapper(noDescription))
|
||||||
|
|
||||||
|
val multiline =
|
||||||
|
"[OUT-OF-BAND USER MESSAGE — direct steer]\n" +
|
||||||
|
"First line\n" +
|
||||||
|
"Second line\n" +
|
||||||
|
"[/OUT-OF-BAND USER MESSAGE]"
|
||||||
|
assertEquals("First line\nSecond line", stripGatewaySteerWrapper(multiline))
|
||||||
|
|
||||||
|
val plain = "Normal user message without wrapper"
|
||||||
|
assertEquals(plain, stripGatewaySteerWrapper(plain))
|
||||||
|
|
||||||
|
val interior = "Mentions [OUT-OF-BAND USER MESSAGE] in passing"
|
||||||
|
assertEquals(interior, stripGatewaySteerWrapper(interior))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun midTurnSteerMessageReconcilesAndSortsChronologically() {
|
||||||
|
val userPrompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "Create a script")
|
||||||
|
val liveSteer =
|
||||||
|
ChatMessage(
|
||||||
|
id = "uuid-steer",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "Use Python instead",
|
||||||
|
)
|
||||||
|
val inFlightAssistant =
|
||||||
|
ChatMessage(
|
||||||
|
id = "uuid-assistant",
|
||||||
|
role = MessageRole.ASSISTANT,
|
||||||
|
content = "Here is the Python script",
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
val current = listOf(userPrompt, inFlightAssistant, liveSteer)
|
||||||
|
|
||||||
|
val serverRows =
|
||||||
|
listOf(
|
||||||
|
SessionMessage(
|
||||||
|
id = 0,
|
||||||
|
role = "user",
|
||||||
|
content = JsonPrimitive("Create a script"),
|
||||||
|
timestamp = JsonPrimitive(0),
|
||||||
|
),
|
||||||
|
SessionMessage(
|
||||||
|
id = 1,
|
||||||
|
role = "user",
|
||||||
|
content =
|
||||||
|
JsonPrimitive(
|
||||||
|
"[OUT-OF-BAND USER MESSAGE — a direct message from the user, " +
|
||||||
|
"delivered once at this position; not tool output and not a new " +
|
||||||
|
"delivery when replayed from conversation history]\n" +
|
||||||
|
"Use Python instead\n" +
|
||||||
|
"[/OUT-OF-BAND USER MESSAGE]",
|
||||||
|
),
|
||||||
|
display_kind = "steer",
|
||||||
|
timestamp = JsonPrimitive(1),
|
||||||
|
),
|
||||||
|
SessionMessage(
|
||||||
|
id = 2,
|
||||||
|
role = "assistant",
|
||||||
|
content = JsonPrimitive("Here is the Python script"),
|
||||||
|
timestamp = JsonPrimitive(2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged = applyServerPage(current, serverRows)
|
||||||
|
|
||||||
|
assertEquals(3, merged.size)
|
||||||
|
assertEquals("rest-session-0", merged[0].id)
|
||||||
|
assertEquals("Create a script", merged[0].content)
|
||||||
|
|
||||||
|
assertEquals("uuid-steer", merged[1].id)
|
||||||
|
assertEquals("rest-session-1", merged[1].canonicalRestId)
|
||||||
|
assertEquals("Use Python instead", merged[1].content)
|
||||||
|
assertEquals("steer", merged[1].displayKind)
|
||||||
|
|
||||||
|
assertEquals("rest-session-2", merged[2].canonicalRestId)
|
||||||
|
assertEquals("Here is the Python script", merged[2].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localNoticesKeepTheirPositionAcrossRepeatedTranscriptSyncs() {
|
||||||
|
val first = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "hello")
|
||||||
|
val answer = ChatMessage(id = "rest-s-2", role = MessageRole.ASSISTANT, content = "hi")
|
||||||
|
val resumed = ChatMessage(id = "resume", role = MessageRole.SYSTEM, content = "Session resumed")
|
||||||
|
val review =
|
||||||
|
ChatMessage(id = "review", role = MessageRole.SYSTEM, content = "💾 Self-improvement review: skill updated")
|
||||||
|
val next = ChatMessage(id = "rest-s-3", role = MessageRole.USER, content = "next")
|
||||||
|
val reply = ChatMessage(id = "rest-s-4", role = MessageRole.ASSISTANT, content = "done")
|
||||||
|
|
||||||
|
val afterResume = mergeTranscriptWithLive(listOf(first, answer), listOf(first, answer, resumed))
|
||||||
|
assertEquals(listOf("rest-s-1", "rest-s-2", "resume"), afterResume.map { it.id })
|
||||||
|
val afterReview = mergeTranscriptWithLive(listOf(first, answer), afterResume + review)
|
||||||
|
assertEquals(listOf("rest-s-1", "rest-s-2", "resume", "review"), afterReview.map { it.id })
|
||||||
|
val afterNextTurn = mergeTranscriptWithLive(listOf(next, reply), afterReview)
|
||||||
|
assertEquals(
|
||||||
|
listOf("rest-s-1", "rest-s-2", "resume", "review", "rest-s-3", "rest-s-4"),
|
||||||
|
afterNextTurn.map { it.id },
|
||||||
|
)
|
||||||
|
assertEquals(afterNextTurn, mergeTranscriptWithLive(listOf(next, reply), afterNextTurn))
|
||||||
|
assertEquals(
|
||||||
|
afterNextTurn.map { it.id },
|
||||||
|
mergeCachedTranscriptPage(listOf(first, answer, next, reply), afterNextTurn).map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localNoticeOnColdResumeFollowsHydratedHistoryButNotFutureReplies() {
|
||||||
|
val resumed = ChatMessage(id = "resume", role = MessageRole.SYSTEM, content = "Session resumed")
|
||||||
|
val prior = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "earlier")
|
||||||
|
val later = ChatMessage(id = "rest-s-2", role = MessageRole.ASSISTANT, content = "later")
|
||||||
|
val created = ChatMessage(id = "created", role = MessageRole.SYSTEM, content = "Session created")
|
||||||
|
val hydrated = mergeTranscriptWithLive(listOf(prior), listOf(created, resumed))
|
||||||
|
assertEquals(listOf("created", "rest-s-1", "resume"), hydrated.map { it.id })
|
||||||
|
val synced = mergeTranscriptWithLive(listOf(later), hydrated)
|
||||||
|
assertEquals(listOf("created", "rest-s-1", "resume", "rest-s-2"), synced.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unconfirmedUserAndAssistantRemainAfterCanonicalHistory() {
|
||||||
|
val user = ChatMessage(id = "uuid-user", role = MessageRole.USER, content = "pending")
|
||||||
|
val assistant = ChatMessage(id = "uuid-assistant", role = MessageRole.ASSISTANT, content = "streaming")
|
||||||
|
val canonical = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "older")
|
||||||
|
val merged = mergeTranscriptWithLive(listOf(canonical), listOf(user, assistant))
|
||||||
|
assertEquals(listOf("rest-s-1", "uuid-user", "uuid-assistant"), merged.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun olderPageDoesNotMoveLocalNoticeOrReverseCanonicalRows() {
|
||||||
|
val old = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "old")
|
||||||
|
val current = ChatMessage(id = "rest-s-3", role = MessageRole.ASSISTANT, content = "current")
|
||||||
|
val notice = ChatMessage(id = "notice", role = MessageRole.SYSTEM, content = "Session interrupted")
|
||||||
|
val next = ChatMessage(id = "rest-s-4", role = MessageRole.USER, content = "next")
|
||||||
|
|
||||||
|
val paged = mergeTranscriptWithLive(listOf(old), listOf(current, notice), chronological = false)
|
||||||
|
assertEquals(listOf("rest-s-1", "rest-s-3", "notice"), paged.map { it.id })
|
||||||
|
val synced = mergeTranscriptWithLive(listOf(next), paged)
|
||||||
|
assertEquals(listOf("rest-s-1", "rest-s-3", "notice", "rest-s-4"), synced.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reviewNoticeStaysAfterAssistantWhenItsRestEchoArrivesLater() {
|
||||||
|
val user = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "prompt")
|
||||||
|
val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = "answer")
|
||||||
|
val review = ChatMessage(id = "review", role = MessageRole.SYSTEM, content = "💾 Self-improvement review")
|
||||||
|
val beforeEcho = mergeTranscriptWithLive(listOf(user), listOf(user, liveReply, review), preserveLiveIds = true)
|
||||||
|
assertEquals(listOf("rest-s-1", "live-reply", "review"), beforeEcho.map { it.id })
|
||||||
|
val restReply = liveReply.copy(id = "rest-s-2")
|
||||||
|
val afterEcho = mergeTranscriptWithLive(listOf(restReply), beforeEcho, preserveLiveIds = true)
|
||||||
|
assertEquals(listOf("rest-s-1", "live-reply", "review"), afterEcho.map { it.id })
|
||||||
|
val next = ChatMessage(id = "rest-s-3", role = MessageRole.USER, content = "next")
|
||||||
|
val synced = mergeTranscriptWithLive(listOf(next), afterEcho)
|
||||||
|
assertEquals(listOf("rest-s-1", "live-reply", "review", "rest-s-3"), synced.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionCreatedMarkerStaysAtStartOfTranscript() {
|
||||||
|
val sessionCreated =
|
||||||
|
ChatMessage(
|
||||||
|
id = "uuid-sys-start",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "Session created",
|
||||||
|
)
|
||||||
|
val current = listOf(sessionCreated)
|
||||||
|
|
||||||
|
val serverRows =
|
||||||
|
listOf(
|
||||||
|
SessionMessage(
|
||||||
|
id = 0,
|
||||||
|
role = "user",
|
||||||
|
content = JsonPrimitive("Hello"),
|
||||||
|
timestamp = JsonPrimitive(1),
|
||||||
|
),
|
||||||
|
SessionMessage(
|
||||||
|
id = 1,
|
||||||
|
role = "assistant",
|
||||||
|
content = JsonPrimitive("Hi there!"),
|
||||||
|
timestamp = JsonPrimitive(2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged = applyServerPage(current, serverRows)
|
||||||
|
|
||||||
|
assertEquals(3, merged.size)
|
||||||
|
assertEquals("uuid-sys-start", merged[0].id)
|
||||||
|
assertEquals("Session created", merged[0].content)
|
||||||
|
assertEquals("rest-session-0", merged[1].canonicalRestId)
|
||||||
|
assertEquals("rest-session-1", merged[2].canonicalRestId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun assertBoundaryTranscript(messages: List<ChatMessage>) {
|
private fun assertBoundaryTranscript(messages: List<ChatMessage>) {
|
||||||
assertEquals(listOf("rest-session-100", "rest-session-200"), messages.map { it.canonicalRestId })
|
assertEquals(listOf("rest-session-100", "rest-session-200"), messages.map { it.canonicalRestId })
|
||||||
assertEquals(listOf("boundary trace", ""), messages.map { it.reasoningText })
|
assertEquals(listOf("boundary trace", ""), messages.map { it.reasoningText })
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ class FullBleedTurnsTest {
|
|||||||
assertEquals(listOf<ChatTurn>(entries(AgentEntry.SystemEvent(marker))), result)
|
assertEquals(listOf<ChatTurn>(entries(AgentEntry.SystemEvent(marker))), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `steer message with display_kind steer is a User turn not a SystemEvent entry`() {
|
||||||
|
val steer =
|
||||||
|
msg("u1", MessageRole.USER, content = "Stop and run tests")
|
||||||
|
.copy(displayKind = "steer")
|
||||||
|
val result = groupIntoTurns(listOf(steer))
|
||||||
|
assertEquals(listOf<ChatTurn>(ChatTurn.User(steer)), result)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `max-iterations nudge is a SystemEvent not a user bubble`() {
|
fun `max-iterations nudge is a SystemEvent not a user bubble`() {
|
||||||
// Backend persists the max-iterations notice as a plain role=user row
|
// Backend persists the max-iterations notice as a plain role=user row
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat.fakes
|
|||||||
import com.m57.hermescontrol.data.local.ChatMessageDao
|
import com.m57.hermescontrol.data.local.ChatMessageDao
|
||||||
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
||||||
import com.m57.hermescontrol.data.local.canonicalMessageOrder
|
import com.m57.hermescontrol.data.local.canonicalMessageOrder
|
||||||
|
import com.m57.hermescontrol.data.local.isSessionStartMarker
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.ConcurrentMap
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
|
||||||
@@ -86,7 +87,12 @@ class FakeChatMessageDao : ChatMessageDao {
|
|||||||
fun addMessageDirect(message: ChatMessageEntity) {
|
fun addMessageDirect(message: ChatMessageEntity) {
|
||||||
val existing = messages[message.id]
|
val existing = messages[message.id]
|
||||||
if (existing == null) insertionSequence++
|
if (existing == null) insertionSequence++
|
||||||
val order = canonicalMessageOrder(message.restId ?: message.id, message.sessionId)
|
val order =
|
||||||
|
if (message.isSessionStartMarker()) {
|
||||||
|
-1L
|
||||||
|
} else {
|
||||||
|
canonicalMessageOrder(message.restId ?: message.id, message.sessionId)
|
||||||
|
}
|
||||||
messages[message.id] =
|
messages[message.id] =
|
||||||
message.copy(
|
message.copy(
|
||||||
sortGroup = if (order != null) 0 else 1,
|
sortGroup = if (order != null) 0 else 1,
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import com.m57.hermescontrol.data.update.AppUpdateCache
|
|||||||
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
import com.m57.hermescontrol.data.update.UpdateInfo
|
import com.m57.hermescontrol.data.update.UpdateInfo
|
||||||
|
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.slot
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -48,6 +50,7 @@ class AppUpdateViewModelTest {
|
|||||||
private lateinit var app: Application
|
private lateinit var app: Application
|
||||||
private lateinit var packageManager: PackageManager
|
private lateinit var packageManager: PackageManager
|
||||||
private lateinit var checker: AppUpdateChecker
|
private lateinit var checker: AppUpdateChecker
|
||||||
|
private var rcUpdatesEnabled = false
|
||||||
|
|
||||||
private val currentVersion = "1.21.0"
|
private val currentVersion = "1.21.0"
|
||||||
|
|
||||||
@@ -70,6 +73,8 @@ class AppUpdateViewModelTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
UpdateNoticeManager.resetForTests()
|
||||||
|
UpdateNoticeManager.enabled = true
|
||||||
AppUpdateCache.reset()
|
AppUpdateCache.reset()
|
||||||
mockkObject(AuthManager)
|
mockkObject(AuthManager)
|
||||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
||||||
@@ -79,8 +84,12 @@ class AppUpdateViewModelTest {
|
|||||||
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
|
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
|
||||||
every { AuthManager.getDismissedUpdateTag() } returns null
|
every { AuthManager.getDismissedUpdateTag() } returns null
|
||||||
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
||||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
rcUpdatesEnabled = false
|
||||||
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit
|
every { AuthManager.isCheckingReleaseCandidateUpdates() } answers { rcUpdatesEnabled }
|
||||||
|
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } answers {
|
||||||
|
rcUpdatesEnabled = firstArg()
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
app = mockk(relaxed = true)
|
app = mockk(relaxed = true)
|
||||||
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
||||||
@@ -91,11 +100,17 @@ class AppUpdateViewModelTest {
|
|||||||
|
|
||||||
checker = mockk()
|
checker = mockk()
|
||||||
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
||||||
coEvery { checker.downloadApk(any(), any(), any()) } returns true
|
val downloadDestination = slot<File>()
|
||||||
|
coEvery { checker.downloadApk(any(), capture(downloadDestination), any()) } coAnswers {
|
||||||
|
downloadDestination.captured.writeBytes(byteArrayOf(1))
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
UpdateNoticeManager.resetForTests()
|
||||||
|
UpdateNoticeManager.enabled = false
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
Dispatchers.resetMain()
|
Dispatchers.resetMain()
|
||||||
}
|
}
|
||||||
@@ -237,14 +252,14 @@ class AppUpdateViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun checkForUpdate_marksDoneEvenWhenCheckFails() =
|
fun checkForUpdate_marksDoneOnlyAfterSuccessfulDiscovery() =
|
||||||
runTest {
|
runTest {
|
||||||
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
|
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
coVerify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update flow ─────────────────────────────────────────────────────
|
// ── Update flow ─────────────────────────────────────────────────────
|
||||||
@@ -351,7 +366,7 @@ class AppUpdateViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") }
|
verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") }
|
||||||
assertTrue(AppUpdateCache.dismissed)
|
assertFalse(AppUpdateCache.isDismissed("v1.22.0"))
|
||||||
assertFalse(AppUpdateCache.isDialogVisible)
|
assertFalse(AppUpdateCache.isDialogVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +407,7 @@ class AppUpdateViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun init_adoptsCachedRcOfferWhenOptedIn() =
|
fun init_adoptsCachedRcOfferWhenOptedIn() =
|
||||||
runTest {
|
runTest {
|
||||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
rcUpdatesEnabled = true
|
||||||
AppUpdateCache.update(
|
AppUpdateCache.update(
|
||||||
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
|
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
|
||||||
)
|
)
|
||||||
@@ -419,6 +434,7 @@ class AppUpdateViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.checkReleaseCandidateUpdates.value)
|
assertTrue(vm.checkReleaseCandidateUpdates.value)
|
||||||
|
verify(exactly = 1) { UpdateNoticeManager.channelChanged(true) }
|
||||||
verify(exactly = 1) { AuthManager.setCheckReleaseCandidateUpdates(true) }
|
verify(exactly = 1) { AuthManager.setCheckReleaseCandidateUpdates(true) }
|
||||||
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
|
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
|
||||||
assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag)
|
assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag)
|
||||||
@@ -435,13 +451,14 @@ class AppUpdateViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) }
|
verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) }
|
||||||
|
verify(exactly = 0) { UpdateNoticeManager.channelChanged(any()) }
|
||||||
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() =
|
fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() =
|
||||||
runTest {
|
runTest {
|
||||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
rcUpdatesEnabled = true
|
||||||
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
|
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
@@ -487,7 +504,7 @@ class AppUpdateViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() =
|
fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() =
|
||||||
runTest {
|
runTest {
|
||||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
rcUpdatesEnabled = true
|
||||||
val rcGate = CompletableDeferred<Unit>()
|
val rcGate = CompletableDeferred<Unit>()
|
||||||
coEvery { checker.fetchLatestRelease(true) } coAnswers {
|
coEvery { checker.fetchLatestRelease(true) } coAnswers {
|
||||||
withContext(NonCancellable) { rcGate.await() }
|
withContext(NonCancellable) { rcGate.await() }
|
||||||
|
|||||||
Reference in New Issue
Block a user