Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bee0c46eff | |||
| e752a456f7 | |||
| 36ba06c9b9 | |||
| c7630b409b | |||
| 836a2af55b | |||
| ccc2847418 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
+80
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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)
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
|
||||
+145
-22
@@ -32,9 +32,11 @@ import io.element.android.features.announcement.api.AnnouncementService
|
||||
import io.element.android.features.home.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.home.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.home.impl.filters.into
|
||||
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
|
||||
import io.element.android.features.home.impl.search.RoomListSearchEvent
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.blapAvailableFilters
|
||||
import io.element.android.features.home.impl.spacefilters.into
|
||||
import io.element.android.features.home.impl.spacefilters.selectedFilter
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
@@ -46,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
|
||||
@@ -93,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
|
||||
|
||||
@@ -119,30 +126,77 @@ class RoomListPresenter(
|
||||
sharedPreferences.getStringSet(BLAP_VOICE_ROOMS_KEY, null).orEmpty().map { RoomId(it) }.toSet()
|
||||
)
|
||||
}
|
||||
// Blap: the selected space's true child list — used for voice classification AND
|
||||
// unioned into the room filter (the SDK's descendants list omits DM portals, which
|
||||
// left bridge spaces like mautrix-telegram's looking empty).
|
||||
var blapSpaceChildIds by remember { mutableStateOf(emptySet<RoomId>()) }
|
||||
LaunchedEffect(selectedSpaceId) {
|
||||
blapSpaceChildIds = emptySet()
|
||||
if (selectedSpaceId == null) return@LaunchedEffect
|
||||
val spaceRoomList = client.spaceService.spaceRoomList(selectedSpaceId)
|
||||
try {
|
||||
spaceRoomList.loadAllIncrementally(this)
|
||||
spaceRoomList.spaceRoomsFlow.collect { spaceRooms ->
|
||||
blapSpaceChildIds = spaceRooms.map { it.roomId }.toSet()
|
||||
val voiceIds = spaceRooms.filter { it.roomType.isBlapVoiceRoom() }.map { it.roomId }
|
||||
if (voiceIds.isNotEmpty() && !blapVoiceRoomIds.containsAll(voiceIds)) {
|
||||
blapVoiceRoomIds = blapVoiceRoomIds + voiceIds
|
||||
sharedPreferences.edit {
|
||||
putStringSet(BLAP_VOICE_ROOMS_KEY, blapVoiceRoomIds.map { it.value }.toSet())
|
||||
// Blap: live child lists for EVERY top-level space, not just the selected one. Needed to
|
||||
// (a) union real children into the selected space's filter (the SDK's descendants list
|
||||
// omits DM portals, which left bridge spaces like mautrix-telegram's looking empty),
|
||||
// (b) classify voice rooms without having to visit each space first, and
|
||||
// (c) hide space-owned rooms from Home (Discord semantics: channels live in servers).
|
||||
val spaceFilters = spaceFiltersState.blapAvailableFilters
|
||||
val spaceIds = spaceFilters.map { it.spaceRoom.roomId }
|
||||
var blapSpaceChildren by remember { mutableStateOf(emptyMap<RoomId, Set<RoomId>>()) }
|
||||
LaunchedEffect(spaceIds) {
|
||||
for (spaceId in spaceIds) {
|
||||
launch {
|
||||
val spaceRoomList = client.spaceService.spaceRoomList(spaceId)
|
||||
try {
|
||||
spaceRoomList.loadAllIncrementally(this)
|
||||
spaceRoomList.spaceRoomsFlow.collect { spaceRooms ->
|
||||
blapSpaceChildren = blapSpaceChildren + (spaceId to spaceRooms.map { it.roomId }.toSet())
|
||||
val voiceIds = spaceRooms.filter { it.roomType.isBlapVoiceRoom() }.map { it.roomId }
|
||||
if (voiceIds.isNotEmpty() && !blapVoiceRoomIds.containsAll(voiceIds)) {
|
||||
blapVoiceRoomIds = blapVoiceRoomIds + voiceIds
|
||||
sharedPreferences.edit {
|
||||
putStringSet(BLAP_VOICE_ROOMS_KEY, blapVoiceRoomIds.map { it.value }.toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
spaceRoomList.destroy()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
spaceRoomList.destroy()
|
||||
}
|
||||
}
|
||||
// Blap: union of everything owned by any space (real children + SDK descendants),
|
||||
// persisted so Home is clean right away on cold start. Grows immediately as spaces
|
||||
// report in; replaced exactly once every space has reported (so rooms removed from
|
||||
// a space eventually un-hide).
|
||||
var blapSpaceOwnedIds by remember {
|
||||
mutableStateOf(
|
||||
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 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 +
|
||||
// invites. Only space-owned NON-DM rooms are hidden at Home — DMs and invites always
|
||||
// show, exactly like the desktop sidebar. The DM/invite exemption is applied where
|
||||
// the summaries are filtered, since it needs per-room data.
|
||||
val blapHiddenAtHome = if (selectedSpaceId == null) blapSpaceOwnedIds else emptySet()
|
||||
// Blap: per-space section collapse state, persisted like desktop's localStorage key.
|
||||
// At Home the channel sections start collapsed (DMs stay in reach on a phone);
|
||||
// inside a space they start expanded. User toggles win once persisted.
|
||||
@@ -176,6 +230,18 @@ class RoomListPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
// Blap: in-app update check — every cold start (one cheap GET to our own Gitea);
|
||||
// an update is offered the first launch after it's published, no waiting window.
|
||||
var blapUpdateInfo by remember { mutableStateOf<BlapUpdateInfo?>(null) }
|
||||
var blapUpdateDownloading by remember { mutableStateOf(false) }
|
||||
var blapUpdateFailed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
val info = blapUpdateChecker.check()
|
||||
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 ->
|
||||
@@ -242,15 +308,50 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(filtersState.filterSelectionStates, spaceFiltersState.selectedFilter(), blapSpaceChildIds) {
|
||||
LaunchedEffect(filtersState.filterSelectionStates, spaceFiltersState.selectedFilter(), blapSpaceChildren, spaceFilters) {
|
||||
val selectedFilters = filtersState.selectedFilters().map { filter -> filter.into() }
|
||||
// Blap: union the space's real child list into the SDK's descendants — bridge
|
||||
// spaces whose children are DM portals (mautrix-telegram) otherwise come up empty.
|
||||
// Expansion is RECURSIVE like desktop's SpaceStore: container spaces (mautrix-discord
|
||||
// nests server/DM spaces inside a top space) contribute their sub-spaces' rooms too,
|
||||
// otherwise selecting the container matches no rooms at all.
|
||||
val selectedSpaceFilter = spaceFiltersState.selectedFilter()?.let { filter ->
|
||||
RoomListFilter.Identifiers((filter.descendants.toSet() + blapSpaceChildIds).toList())
|
||||
val descendantsBySpace = spaceFilters.associate { it.spaceRoom.roomId to it.descendants }
|
||||
val ids = mutableSetOf<RoomId>()
|
||||
val visited = mutableSetOf<RoomId>()
|
||||
val queue = ArrayDeque(listOf(filter.spaceRoom.roomId))
|
||||
while (queue.isNotEmpty()) {
|
||||
val spaceId = queue.removeFirst()
|
||||
if (!visited.add(spaceId)) continue
|
||||
val direct = blapSpaceChildren[spaceId].orEmpty() + descendantsBySpace[spaceId].orEmpty()
|
||||
for (id in direct) {
|
||||
ids += id
|
||||
// Known space → recurse into its children as well.
|
||||
if (id in blapSpaceChildren || id in descendantsBySpace) queue += id
|
||||
}
|
||||
}
|
||||
RoomListFilter.Identifiers(ids.toList())
|
||||
}
|
||||
val allFilters = RoomListFilter.All(selectedFilters + listOfNotNull(selectedSpaceFilter))
|
||||
roomListDataSource.updateFilter(allFilters)
|
||||
@@ -270,6 +371,14 @@ class RoomListPresenter(
|
||||
blapManualOrder,
|
||||
blapCallUsers,
|
||||
blapReorderMode,
|
||||
blapHiddenAtHome,
|
||||
blapUpdateInfo?.let { info ->
|
||||
BlapUpdateBannerState(
|
||||
version = info.version,
|
||||
downloading = blapUpdateDownloading,
|
||||
failed = blapUpdateFailed,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
return RoomListState(
|
||||
@@ -283,6 +392,7 @@ class RoomListPresenter(
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
hideInvitesAvatars = hideInvitesAvatar,
|
||||
canReportRoom = canReportRoom,
|
||||
blapNestedSpaceIds = blapNestedSpaceIds.toImmutableSet(),
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
@@ -328,6 +438,9 @@ 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_DISMISSED_KEY = "blap_update_dismissed_version"
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -340,6 +453,8 @@ class RoomListPresenter(
|
||||
blapManualOrder: List<RoomId>,
|
||||
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) }
|
||||
@@ -371,13 +486,21 @@ class RoomListPresenter(
|
||||
showUnreadCount = showUnreadCount,
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||
batteryOptimizationState = batteryOptimizationPresenter.present(),
|
||||
summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(),
|
||||
summaries = roomSummaries.dataOrNull().orEmpty()
|
||||
.filter { summary ->
|
||||
// Desktop's showInHomeSpace rule: DMs and invites always show.
|
||||
summary.roomId !in blapHiddenAtHome ||
|
||||
summary.isDm ||
|
||||
summary.displayType == RoomSummaryDisplayType.INVITE
|
||||
}
|
||||
.toImmutableList(),
|
||||
seenRoomInvites = seenRoomInvites.toImmutableSet(),
|
||||
blapVoiceRoomIds = blapVoiceRoomIds.toImmutableSet(),
|
||||
blapCollapsedSections = blapCollapsedSections.toImmutableSet(),
|
||||
blapManualOrder = blapManualOrder.toImmutableList(),
|
||||
blapCallUsers = blapCallUsers.toImmutableMap(),
|
||||
blapReorderMode = blapReorderMode,
|
||||
blapUpdateBanner = blapUpdateBanner,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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,
|
||||
)
|
||||
|
||||
+7
-3
@@ -87,11 +87,15 @@ class SpaceFiltersPresenter(
|
||||
)
|
||||
}
|
||||
is SelectionMode.Selected -> {
|
||||
var selectedFilter by remember { mutableStateOf(mode.filter) }
|
||||
// Blap: keyed on the space id — switching straight from one selected space to
|
||||
// another stays in this branch, and an unkeyed remember would keep serving the
|
||||
// previous space's filter (rail taps looked dead until going through Home).
|
||||
val selectedRoomId = mode.filter.spaceRoom.roomId
|
||||
var selectedFilter by remember(selectedRoomId) { mutableStateOf(mode.filter) }
|
||||
// Makes sure the selectedFilter stays in sync with the available filters
|
||||
LaunchedEffect(availableFilters) {
|
||||
LaunchedEffect(availableFilters, selectedRoomId) {
|
||||
val upToDateFilter = availableFilters
|
||||
.firstOrNull { it.spaceRoom.roomId == mode.filter.spaceRoom.roomId }
|
||||
.firstOrNull { it.spaceRoom.roomId == selectedRoomId }
|
||||
if (upToDateFilter == null) {
|
||||
selectionMode = SelectionMode.Unselected
|
||||
} else {
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.roomlist
|
||||
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/** Minimal in-memory SharedPreferences for JVM unit tests. */
|
||||
class InMemorySharedPreferences : SharedPreferences {
|
||||
private val values = mutableMapOf<String, Any?>()
|
||||
|
||||
override fun getAll(): Map<String, *> = values.toMap()
|
||||
|
||||
override fun getString(key: String, defValue: String?): String? = values[key] as? String ?: defValue
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String>? =
|
||||
(values[key] as? Set<String>)?.toMutableSet() ?: defValues
|
||||
|
||||
override fun getInt(key: String, defValue: Int): Int = values[key] as? Int ?: defValue
|
||||
|
||||
override fun getLong(key: String, defValue: Long): Long = values[key] as? Long ?: defValue
|
||||
|
||||
override fun getFloat(key: String, defValue: Float): Float = values[key] as? Float ?: defValue
|
||||
|
||||
override fun getBoolean(key: String, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
|
||||
|
||||
override fun contains(key: String): Boolean = key in values
|
||||
|
||||
override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor {
|
||||
private val pending = mutableMapOf<String, Any?>()
|
||||
private var clearFirst = false
|
||||
|
||||
override fun putString(key: String, value: String?) = apply { pending[key] = value }
|
||||
override fun putStringSet(key: String, value: MutableSet<String>?) = apply { pending[key] = value?.toSet() }
|
||||
override fun putInt(key: String, value: Int) = apply { pending[key] = value }
|
||||
override fun putLong(key: String, value: Long) = apply { pending[key] = value }
|
||||
override fun putFloat(key: String, value: Float) = apply { pending[key] = value }
|
||||
override fun putBoolean(key: String, value: Boolean) = apply { pending[key] = value }
|
||||
override fun remove(key: String) = apply { pending[key] = null }
|
||||
override fun clear() = apply { clearFirst = true }
|
||||
|
||||
override fun commit(): Boolean {
|
||||
apply()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
if (clearFirst) values.clear()
|
||||
for ((key, value) in pending) {
|
||||
if (value == null) values.remove(key) else values[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
|
||||
|
||||
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
|
||||
}
|
||||
+8
@@ -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
|
||||
@@ -664,6 +666,9 @@ class RoomListPresenterTest {
|
||||
announcementService: AnnouncementService = FakeAnnouncementService(),
|
||||
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 },
|
||||
@@ -695,5 +700,8 @@ class RoomListPresenterTest {
|
||||
announcementService = announcementService,
|
||||
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
|
||||
featureFlagService = featureFlagService,
|
||||
sharedPreferences = sharedPreferences,
|
||||
blapUpdateChecker = blapUpdateChecker,
|
||||
blapUpdateInstaller = blapUpdateInstaller,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+91
@@ -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,
|
||||
)
|
||||
+13
@@ -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"
|
||||
}
|
||||
+59
@@ -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") }
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -10,10 +10,10 @@ package io.element.android.libraries.pushproviders.ntfy
|
||||
object NtfyConfig {
|
||||
/**
|
||||
* Default ntfy server. Any ntfy instance with the built-in Matrix push gateway works;
|
||||
* the Blap distribution points this at the Blap ntfy VPS. Overridable at runtime via
|
||||
* [NtfyStore.setServerUrl].
|
||||
* this is the dedicated Blap push instance. Overridable at runtime via
|
||||
* [NtfyStore.setServerUrl]. (ntfy.sh is NOT usable — it blocks Matrix gateway traffic.)
|
||||
*/
|
||||
const val DEFAULT_SERVER_URL: String = "https://ntfy.sh"
|
||||
const val DEFAULT_SERVER_URL: String = "https://blappush.utn.lol"
|
||||
|
||||
/**
|
||||
* ntfy tops out at 64 chars per topic; "up" prefix matches the ntfy UnifiedPush
|
||||
|
||||
+50
-11
@@ -14,6 +14,8 @@ import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Base64
|
||||
@@ -35,10 +37,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -76,6 +79,12 @@ class NtfySubscriberService : Service() {
|
||||
private var connectionJob: Job? = null
|
||||
private var currentWebSocket: WebSocket? = null
|
||||
|
||||
// Fired when the default network (re)appears, so the connect loop skips whatever is
|
||||
// left of its backoff delay — a Doze exit or wifi↔cellular switch otherwise leaves
|
||||
// push dead for up to MAX_BACKOFF_MS.
|
||||
private val reconnectKick = Channel<Unit>(Channel.CONFLATED)
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val okHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
@@ -90,6 +99,20 @@ class NtfySubscriberService : Service() {
|
||||
super.onCreate()
|
||||
bindings<NtfySubscriberServiceBindings>().inject(this)
|
||||
serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
reconnectKick.trySend(Unit)
|
||||
// A socket that survived the network change keeps working; a half-dead one
|
||||
// gets torn down here so the loop reconnects immediately (since= resumes).
|
||||
currentWebSocket?.close(NORMAL_CLOSURE, "network changed")
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
getSystemService<ConnectivityManager>()?.registerDefaultNetworkCallback(callback)
|
||||
networkCallback = callback
|
||||
}.onFailure {
|
||||
Timber.tag(loggerTag.value).w("Could not register network callback: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -104,6 +127,9 @@ class NtfySubscriberService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
networkCallback?.let { callback ->
|
||||
runCatching { getSystemService<ConnectivityManager>()?.unregisterNetworkCallback(callback) }
|
||||
}
|
||||
currentWebSocket?.close(NORMAL_CLOSURE, "service destroyed")
|
||||
serviceScope?.cancel()
|
||||
super.onDestroy()
|
||||
@@ -128,8 +154,10 @@ class NtfySubscriberService : Service() {
|
||||
}
|
||||
Timber.tag(loggerTag.value).d("Connecting to ntfy (${topics.size} topics)")
|
||||
val hadMessages = runWebSocket(url)
|
||||
updateNotification("Reconnecting…")
|
||||
backoffMs = if (hadMessages) INITIAL_BACKOFF_MS else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
|
||||
delay(backoffMs)
|
||||
// A network-available kick cuts the wait short so Doze exits reconnect instantly.
|
||||
withTimeoutOrNull(backoffMs) { reconnectKick.receive() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +169,7 @@ class NtfySubscriberService : Service() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
opened = true
|
||||
Timber.tag(loggerTag.value).d("Connected")
|
||||
updateNotification("Connected to ${ntfyStore.getServerUrl().removePrefix("https://").removePrefix("http://")}")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
@@ -203,6 +232,24 @@ class NtfySubscriberService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(contentText: String): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_blap_push)
|
||||
.setContentTitle("Blap")
|
||||
.setContentText(contentText)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Surfaces the socket state in the (minimized) FGS notification — the only way to see
|
||||
* on-device whether push is actually connected without pulling logs. */
|
||||
private fun updateNotification(contentText: String) {
|
||||
getSystemService<NotificationManager>()?.notify(NOTIFICATION_ID, buildNotification(contentText))
|
||||
}
|
||||
|
||||
private fun startAsForeground() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
@@ -213,20 +260,12 @@ class NtfySubscriberService : Service() {
|
||||
setShowBadge(false)
|
||||
}
|
||||
getSystemService<NotificationManager>()?.createNotificationChannel(channel)
|
||||
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_blap_push)
|
||||
.setContentTitle("Blap")
|
||||
.setContentText("Listening for messages")
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.build()
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, type)
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification("Connecting…"), type)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -37,7 +37,7 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
*/
|
||||
private const val versionMajor = 1
|
||||
private const val versionMinor = 0
|
||||
private const val versionPatch = 4
|
||||
private const val versionPatch = 7
|
||||
|
||||
object Versions {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user