diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 479af92e69..5698d94837 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6a3225cb8..ca1b3cd9e7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + = 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 diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapUpdateBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapUpdateBanner.kt new file mode 100644 index 0000000000..8548280632 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapUpdateBanner.kt @@ -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, + ) + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index c80d67a708..c3940593ce 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -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) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt index a3f23b91fb..10393cc0f0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt @@ -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) : RoomListEvent diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 7d71438410..572484bfb3 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -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, private val featureFlagService: FeatureFlagService, private val sharedPreferences: SharedPreferences, + private val blapUpdateChecker: BlapUpdateChecker, + private val blapUpdateInstaller: BlapUpdateInstaller, ) : Presenter { 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(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, blapReorderMode: Boolean, blapHiddenAtHome: Set, + 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, ) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index 5939a55f83..098488d6f4 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -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 = persistentSetOf(), val eventSink: (RoomListEvent) -> Unit, ) { val displayFilters = contentState is RoomListContentState.Rooms @@ -88,5 +92,14 @@ sealed interface RoomListContentState { val blapCallUsers: ImmutableMap = 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, +) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 0d35d0cd3c..834b8d9048 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -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, ) } diff --git a/libraries/blapupdate/build.gradle.kts b/libraries/blapupdate/build.gradle.kts new file mode 100644 index 0000000000..1ddc8c9300 --- /dev/null +++ b/libraries/blapupdate/build.gradle.kts @@ -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) +} diff --git a/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateChecker.kt b/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateChecker.kt new file mode 100644 index 0000000000..8cb7c43eaf --- /dev/null +++ b/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateChecker.kt @@ -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(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 = emptyList(), +) + +@Serializable +internal data class GiteaAsset( + @SerialName("name") val name: String, + @SerialName("browser_download_url") val downloadUrl: String, +) diff --git a/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateConfig.kt b/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateConfig.kt new file mode 100644 index 0000000000..749d8a32ca --- /dev/null +++ b/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateConfig.kt @@ -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" +} diff --git a/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateInstaller.kt b/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateInstaller.kt new file mode 100644 index 0000000000..dac2456da5 --- /dev/null +++ b/libraries/blapupdate/src/main/kotlin/io/element/android/libraries/blapupdate/BlapUpdateInstaller.kt @@ -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 +} + +@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 = 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") } + } +} diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 4d31004507..0b67bf7650 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -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 { /**