Rail shows only root spaces + in-app updater; bump to 1.0.6
Sonar / Sonar Quality Checks (push) Has been cancelled
Post-release / post-release (push) Has been cancelled
Code Quality Checks / Search for forbidden patterns (push) Has been cancelled
Code Quality Checks / Search for invalid screenshot files (push) Has been cancelled
Code Quality Checks / Search for invalid dependencies (push) Has been cancelled
Code Quality Checks / Konsist tests (push) Has been cancelled
Code Quality Checks / Compose tests (push) Has been cancelled
Code Quality Checks / Android lint check (push) Has been cancelled
Code Quality Checks / Detekt checks (push) Has been cancelled
Code Quality Checks / Ktlint checks (push) Has been cancelled
Code Quality Checks / Doc checks (push) Has been cancelled
Code Quality Checks / Check shell scripts (push) Has been cancelled
Code Quality Checks / Run zizmor (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle (Gplay) (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle Enterprise (push) Has been cancelled
Create release App Bundle and APKs / Create APKs (FDroid) (push) Has been cancelled
Test / Runs unit tests (push) Has been cancelled
Code Quality Checks / Project Check Suite (push) Has been cancelled

Rail: desktop parity (element-web rootSpaces) — spaces nested inside another
joined space no longer get a rail entry; their rooms stay reachable through
the parent space. Nested set persisted for clean cold starts.

Updater: new libraries/blapupdate module checks the git.utn.lol release feed
(6h throttle), offers a banner at the top of the chat list, downloads the
ABI-matched APK and hands it to the system installer. Per-version dismissal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 22:46:42 -07:00
parent 836a2af55b
commit c7630b409b
16 changed files with 391 additions and 8 deletions
+1
View File
@@ -278,6 +278,7 @@ dependencies {
}
// Blap: built-in ntfy push provider (no Google, no external distributor app)
implementation(projects.libraries.pushproviders.ntfy)
implementation(projects.libraries.blapupdate)
// Blap: proactive battery-optimization prompt (ntfy websocket needs the Doze exemption)
implementation(projects.libraries.push.impl)
+3
View File
@@ -8,6 +8,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Blap: in-app updater hands downloaded APKs to the system installer. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".ElementXApplication"
android:allowBackup="false"
+1
View File
@@ -30,6 +30,7 @@ dependencies {
implementation(projects.appconfig)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.blapupdate)
implementation(projects.libraries.architecture)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
@@ -260,6 +260,7 @@ private fun HomeScaffold(
) {
BlapSpaceRail(
spaceFiltersState = roomListState.spaceFiltersState,
nestedSpaceIds = roomListState.blapNestedSpaceIds,
)
RoomListContentView(
contentState = roomListState.contentState,
@@ -36,6 +36,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.blapAvailableFilters
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.features.home.impl.spacefilters.blapOnSelect
import io.element.android.features.home.impl.spacefilters.selectedFilter
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -53,9 +54,12 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData
fun BlapSpaceRail(
spaceFiltersState: SpaceFiltersState,
modifier: Modifier = Modifier,
nestedSpaceIds: Set<RoomId> = emptySet(),
) {
if (spaceFiltersState is SpaceFiltersState.Disabled) return
val filters = spaceFiltersState.blapAvailableFilters
// Desktop parity: only root spaces get a rail entry — spaces nested inside another
// joined space are reachable through their parent, not the rail.
val filters = spaceFiltersState.blapAvailableFilters.filter { it.spaceRoom.roomId !in nestedSpaceIds }
val selectedRoomId = spaceFiltersState.selectedFilter()?.spaceRoom?.roomId
val onSelect = spaceFiltersState.blapOnSelect
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2026 Blap
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.home.impl.roomlist.BlapUpdateBannerState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
/**
* Blap: in-app update banner at the top of the chat list. Tapping Install downloads the
* APK and hands off to the system installer — no trip to the git releases page.
*/
@Composable
fun BlapUpdateBanner(
state: BlapUpdateBannerState,
onInstallClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = when {
state.downloading -> "Downloading Blap ${state.version}"
state.failed -> "Update ${state.version} download failed"
else -> "Blap ${state.version} is available"
},
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
if (!state.downloading) {
Text(
text = if (state.failed) "Check your connection and try again" else "Tap install — no browser needed",
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
if (state.downloading) {
CircularProgressIndicator(
modifier = Modifier
.padding(end = 12.dp)
.size(20.dp),
strokeWidth = 2.dp,
)
} else {
TextButton(
text = "Later",
onClick = onDismissClick,
)
TextButton(
text = if (state.failed) "Retry" else "Install",
onClick = onInstallClick,
)
}
}
}
@@ -277,9 +277,18 @@ private fun RoomsViewList(
)
}
}
// Banner precedence (top-to-bottom): full-screen-intent > battery-optimization >
// new-notification-sound > sound-unavailable. At most one renders at a time.
// Banner precedence (top-to-bottom): app-update > full-screen-intent >
// battery-optimization > new-notification-sound. At most one renders at a time.
SecurityBannerState.None -> when {
state.blapUpdateBanner != null -> {
item(key = "blap_update_banner", contentType = "blap_update_banner") {
BlapUpdateBanner(
state = state.blapUpdateBanner,
onInstallClick = { eventSink(RoomListEvent.BlapInstallUpdate) },
onDismissClick = { eventSink(RoomListEvent.BlapDismissUpdate) },
)
}
}
state.fullScreenIntentPermissionsState.shouldDisplayBanner -> {
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
@@ -27,6 +27,10 @@ sealed interface RoomListEvent {
// Blap: collapse/expand a room list section
data class BlapToggleSection(val section: BlapSection) : RoomListEvent
// Blap: in-app update banner actions
data object BlapInstallUpdate : RoomListEvent
data object BlapDismissUpdate : RoomListEvent
// Blap: persist the new manual room order after a drag (full displayed order)
data class BlapSetRoomOrder(val order: List<RoomId>) : RoomListEvent
@@ -48,6 +48,9 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.blapupdate.BlapUpdateChecker
import io.element.android.libraries.blapupdate.BlapUpdateInfo
import io.element.android.libraries.blapupdate.BlapUpdateInstaller
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
@@ -95,6 +98,8 @@ class RoomListPresenter(
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
private val featureFlagService: FeatureFlagService,
private val sharedPreferences: SharedPreferences,
private val blapUpdateChecker: BlapUpdateChecker,
private val blapUpdateInstaller: BlapUpdateInstaller,
) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService
@@ -161,14 +166,31 @@ class RoomListPresenter(
sharedPreferences.getStringSet(BLAP_SPACE_OWNED_KEY, null).orEmpty().map { RoomId(it) }.toSet()
)
}
// Desktop parity (element-web rootSpaces): a space that is itself a child of another
// joined space gets no rail entry of its own — only root spaces make the rail.
// Persisted like the owned set so the rail is right on cold start.
var blapNestedSpaceIds by remember {
mutableStateOf(
sharedPreferences.getStringSet(BLAP_NESTED_SPACES_KEY, null).orEmpty().map { RoomId(it) }.toSet()
)
}
LaunchedEffect(blapSpaceChildren, spaceFilters) {
if (spaceFilters.isEmpty()) return@LaunchedEffect
val union = blapSpaceChildren.values.flatten().toSet() + spaceFilters.flatMap { it.descendants }
val nested = buildSet {
blapSpaceChildren.forEach { (parentId, children) -> addAll(children.filter { it != parentId }) }
spaceFilters.forEach { filter -> addAll(filter.descendants.filter { it != filter.spaceRoom.roomId }) }
}.intersect(spaceIds.toSet())
val allReported = spaceIds.all { it in blapSpaceChildren }
val newValue = if (allReported) union else blapSpaceOwnedIds + union
if (newValue != blapSpaceOwnedIds) {
blapSpaceOwnedIds = newValue
sharedPreferences.edit { putStringSet(BLAP_SPACE_OWNED_KEY, newValue.map { it.value }.toSet()) }
val newOwned = if (allReported) union else blapSpaceOwnedIds + union
if (newOwned != blapSpaceOwnedIds) {
blapSpaceOwnedIds = newOwned
sharedPreferences.edit { putStringSet(BLAP_SPACE_OWNED_KEY, newOwned.map { it.value }.toSet()) }
}
val newNested = if (allReported) nested else blapNestedSpaceIds + nested
if (newNested != blapNestedSpaceIds) {
blapNestedSpaceIds = newNested
sharedPreferences.edit { putStringSet(BLAP_NESTED_SPACES_KEY, newNested.map { it.value }.toSet()) }
}
}
// Desktop parity (SpaceStore.showInHomeSpace): Home = orphaned rooms + ALL DMs +
@@ -209,6 +231,21 @@ class RoomListPresenter(
}
}
// Blap: in-app update check — throttled, so most launches make no network call.
var blapUpdateInfo by remember { mutableStateOf<BlapUpdateInfo?>(null) }
var blapUpdateDownloading by remember { mutableStateOf(false) }
var blapUpdateFailed by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val now = System.currentTimeMillis()
val lastCheck = sharedPreferences.getLong(BLAP_UPDATE_LAST_CHECK_KEY, 0L)
if (now - lastCheck < BLAP_UPDATE_CHECK_INTERVAL_MS) return@LaunchedEffect
val info = blapUpdateChecker.check()
sharedPreferences.edit { putLong(BLAP_UPDATE_LAST_CHECK_KEY, now) }
if (info != null && info.version != sharedPreferences.getString(BLAP_UPDATE_DISMISSED_KEY, null)) {
blapUpdateInfo = info
}
}
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
val showNewNotificationSoundBanner by remember {
announcementService.announcementsToShowFlow().map { announcements ->
@@ -275,6 +312,24 @@ class RoomListPresenter(
is RoomListEvent.BlapSetReorderMode -> {
blapReorderMode = event.enabled
}
RoomListEvent.BlapInstallUpdate -> {
val info = blapUpdateInfo
if (info != null && !blapUpdateDownloading) {
coroutineScope.launch {
blapUpdateDownloading = true
blapUpdateFailed = false
val result = blapUpdateInstaller.downloadAndInstall(info)
blapUpdateDownloading = false
blapUpdateFailed = result.isFailure
}
}
}
RoomListEvent.BlapDismissUpdate -> {
blapUpdateInfo?.let { info ->
sharedPreferences.edit { putString(BLAP_UPDATE_DISMISSED_KEY, info.version) }
}
blapUpdateInfo = null
}
}
}
@@ -304,6 +359,13 @@ class RoomListPresenter(
blapCallUsers,
blapReorderMode,
blapHiddenAtHome,
blapUpdateInfo?.let { info ->
BlapUpdateBannerState(
version = info.version,
downloading = blapUpdateDownloading,
failed = blapUpdateFailed,
)
},
)
return RoomListState(
@@ -317,6 +379,7 @@ class RoomListPresenter(
acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatar,
canReportRoom = canReportRoom,
blapNestedSpaceIds = blapNestedSpaceIds.toImmutableSet(),
eventSink = ::handleEvent,
)
}
@@ -363,6 +426,10 @@ class RoomListPresenter(
private companion object {
const val BLAP_VOICE_ROOMS_KEY = "blap_voice_room_ids"
const val BLAP_SPACE_OWNED_KEY = "blap_space_owned_room_ids"
const val BLAP_NESTED_SPACES_KEY = "blap_nested_space_ids"
const val BLAP_UPDATE_LAST_CHECK_KEY = "blap_update_last_check"
const val BLAP_UPDATE_DISMISSED_KEY = "blap_update_dismissed_version"
const val BLAP_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
}
@Composable
@@ -376,6 +443,7 @@ class RoomListPresenter(
blapCallUsers: Map<UserId, MatrixUser>,
blapReorderMode: Boolean,
blapHiddenAtHome: Set<RoomId>,
blapUpdateBanner: BlapUpdateBannerState?,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
@@ -421,6 +489,7 @@ class RoomListPresenter(
blapManualOrder = blapManualOrder.toImmutableList(),
blapCallUsers = blapCallUsers.toImmutableMap(),
blapReorderMode = blapReorderMode,
blapUpdateBanner = blapUpdateBanner,
)
}
}
@@ -25,6 +25,7 @@ import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
data class RoomListState(
val contextMenu: ContextMenu,
@@ -37,6 +38,9 @@ data class RoomListState(
val acceptDeclineInviteState: AcceptDeclineInviteState,
val hideInvitesAvatars: Boolean,
val canReportRoom: Boolean,
// Blap: spaces that are children of another joined space — desktop shows only root
// spaces in its rail, so these get no rail entry (see element-web SpaceStore.rootSpaces).
val blapNestedSpaceIds: ImmutableSet<RoomId> = persistentSetOf(),
val eventSink: (RoomListEvent) -> Unit,
) {
val displayFilters = contentState is RoomListContentState.Rooms
@@ -88,5 +92,14 @@ sealed interface RoomListContentState {
val blapCallUsers: ImmutableMap<UserId, MatrixUser> = persistentMapOf(),
// Blap: drag-to-arrange mode (whole rows become drag handles, Done exits)
val blapReorderMode: Boolean = false,
// Blap: in-app update banner (null = no update to offer)
val blapUpdateBanner: BlapUpdateBannerState? = null,
) : RoomListContentState
}
// Blap: state of the in-app update banner at the top of the chat list.
data class BlapUpdateBannerState(
val version: String,
val downloading: Boolean = false,
val failed: Boolean = false,
)
@@ -33,6 +33,8 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.blapupdate.BlapUpdateChecker
import io.element.android.libraries.blapupdate.BlapUpdateInstaller
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
@@ -665,6 +667,8 @@ class RoomListPresenterTest {
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null,
sharedPreferences: android.content.SharedPreferences = InMemorySharedPreferences(),
blapUpdateChecker: BlapUpdateChecker = BlapUpdateChecker { null },
blapUpdateInstaller: BlapUpdateInstaller = BlapUpdateInstaller { Result.success(Unit) },
) = RoomListPresenter(
client = client,
leaveRoomPresenter = { leaveRoomState },
@@ -697,5 +701,7 @@ class RoomListPresenterTest {
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = featureFlagService,
sharedPreferences = sharedPreferences,
blapUpdateChecker = blapUpdateChecker,
blapUpdateInstaller = blapUpdateInstaller,
)
}
+29
View File
@@ -0,0 +1,29 @@
import extension.setupDependencyInjection
/*
* Copyright (c) 2026 Blap
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.libraries.blapupdate"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(libs.androidx.corektx)
implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp.okhttp)
implementation(libs.serialization.json)
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2026 Blap
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.blapupdate
import android.os.Build
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
data class BlapUpdateInfo(
val version: String,
val apkUrl: String,
)
fun interface BlapUpdateChecker {
/** Returns the newer release to offer, or null when up to date (or on any error). */
suspend fun check(): BlapUpdateInfo?
}
@ContributesBinding(AppScope::class)
@Inject
class DefaultBlapUpdateChecker(
private val okHttpClient: OkHttpClient,
private val buildMeta: BuildMeta,
private val coroutineDispatchers: CoroutineDispatchers,
) : BlapUpdateChecker {
private val json = Json { ignoreUnknownKeys = true }
override suspend fun check(): BlapUpdateInfo? = withContext(coroutineDispatchers.io) {
val release = fetchLatestRelease() ?: return@withContext null
val remoteVersion = release.tagName.removePrefix("v")
val localVersion = buildMeta.versionName.substringBefore(" ")
if (!isNewer(remote = remoteVersion, local = localVersion)) return@withContext null
// Prefer the APK matching the device's primary ABI, fall back to universal.
val abi = Build.SUPPORTED_ABIS.firstOrNull().orEmpty()
val asset = release.assets.firstOrNull { abi.isNotEmpty() && it.name.contains(abi) }
?: release.assets.firstOrNull { it.name.contains("universal") }
?: return@withContext null
BlapUpdateInfo(version = remoteVersion, apkUrl = asset.downloadUrl)
}
private fun fetchLatestRelease(): GiteaRelease? {
return runCatching {
val request = Request.Builder().url(BlapUpdateConfig.LATEST_RELEASE_URL).build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
json.decodeFromString<GiteaRelease>(response.body?.string().orEmpty())
}
}
.onFailure { Timber.w("Update check failed: ${it.message}") }
.getOrNull()
}
/** Numeric semver compare; malformed versions are never treated as newer. */
private fun isNewer(remote: String, local: String): Boolean {
val remoteParts = remote.split(".").map { it.toIntOrNull() ?: return false }
val localParts = local.split(".").map { it.toIntOrNull() ?: return false }
for (i in 0 until maxOf(remoteParts.size, localParts.size)) {
val r = remoteParts.getOrElse(i) { 0 }
val l = localParts.getOrElse(i) { 0 }
if (r != l) return r > l
}
return false
}
}
@Serializable
internal data class GiteaRelease(
@SerialName("tag_name") val tagName: String,
@SerialName("assets") val assets: List<GiteaAsset> = emptyList(),
)
@Serializable
internal data class GiteaAsset(
@SerialName("name") val name: String,
@SerialName("browser_download_url") val downloadUrl: String,
)
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 Blap
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.blapupdate
object BlapUpdateConfig {
/** Public Gitea API — same feed Obtainium and the desktop updater use. */
const val LATEST_RELEASE_URL: String = "https://git.utn.lol/api/v1/repos/enki/blap-android/releases/latest"
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026 Blap
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.blapupdate
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.ApplicationContext
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.io.File
fun interface BlapUpdateInstaller {
/**
* Downloads the APK to cache and hands it to the system installer.
* The user still confirms the install (and, once, allows Blap to install apps).
*/
suspend fun downloadAndInstall(info: BlapUpdateInfo): Result<Unit>
}
@ContributesBinding(AppScope::class)
@Inject
class DefaultBlapUpdateInstaller(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val coroutineDispatchers: CoroutineDispatchers,
) : BlapUpdateInstaller {
override suspend fun downloadAndInstall(info: BlapUpdateInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
val dir = File(context.cacheDir, "blap-update").apply { mkdirs() }
// Only ever keep the APK being installed right now.
dir.listFiles()?.forEach { it.delete() }
val apk = File(dir, "blap-${info.version}.apk")
val request = Request.Builder().url(info.apkUrl).build()
okHttpClient.newCall(request).execute().use { response ->
check(response.isSuccessful) { "Download failed: HTTP ${response.code}" }
val body = checkNotNull(response.body) { "Empty download response" }
apk.outputStream().use { output -> body.byteStream().copyTo(output) }
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
}.onFailure { Timber.w(it, "Update install failed") }
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion
*/
private const val versionMajor = 1
private const val versionMinor = 0
private const val versionPatch = 5
private const val versionPatch = 6
object Versions {
/**