Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import android.os.Build
import androidx.core.content.ContextCompat
import java.util.UUID
import org.matrix.vector.manager.BuildConfig
import org.matrix.vector.manager.di.ServiceLocator
import org.matrix.vector.manager.logE
import org.matrix.vector.manager.logW
import org.matrix.vector.manager.R
Expand Down Expand Up @@ -72,11 +73,68 @@ object LaunchShortcut {
.onFailure { logW("actions: pin support query failed", it) }
.getOrDefault(false)

/**
* Whether *some* launcher on this device holds the shortcut.
*
* Not the same question as whether the reader can see it, which is [isPinnedHere]. The pin flag
* is a property of the shortcut and not of the launcher that asked for it, so this stays true
* for a launcher that has since been replaced.
*/
fun isPinned(context: Context): Boolean =
runCatching { manager(context)?.pinnedShortcuts.orEmpty().any { it.id == ID } }
.onFailure { logW("actions: pinned shortcut query failed", it) }
.getOrDefault(false)

/**
* Whether the shortcut is on the home screen the reader is actually looking at.
*
* Installing a different launcher does not carry pinned shortcuts across — the new one starts
* with an empty home screen — but [isPinned] keeps saying yes, because the platform records the
* pin on the shortcut rather than on the pair and only lets the active launcher read the
* per-launcher sets. So the row offering to create one showed a tick over a home screen with no
* Vector on it, and there was no way to ask for another: #883.
*
* The launchers that have pinned it are therefore remembered on this side, in
* SettingsRepository. A device with nothing recorded is one that pinned the shortcut before this
* was written, or by some route that never came back through [request]; rather than tell that
* reader their shortcut is missing, the launcher they are on now is adopted as its owner, which
* is almost certainly true and makes the *next* launcher change detectable.
*/
fun isPinnedHere(context: Context): Boolean {
if (!isPinned(context)) return false
// No answer is not a mismatch. A device whose default home cannot be resolved is not one we
// may tell that its shortcut has gone.
val launcher = currentLauncher(context) ?: return true
val settings = ServiceLocator.settings
val known = settings.shortcutLaunchers()
if (known.isEmpty()) {
settings.noteShortcutLauncher(launcher)
return true
}
return launcher in known
}

/**
* The package drawing the home screen, or null when the device will not say which.
*
* Null covers two cases that must both be read as "do not know": the query failing, and it
* resolving to the platform's own chooser, which is what a device with several launchers and no
* default answers. Neither is evidence that the shortcut is somewhere the reader cannot see.
*/
fun currentLauncher(context: Context): String? =
runCatching {
context.packageManager
.resolveActivity(
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
PackageManager.MATCH_DEFAULT_ONLY,
)
?.activityInfo
?.packageName
?.takeIf { it != RESOLVER_PACKAGE }
}
.onFailure { logW("actions: current launcher query failed", it) }
.getOrNull()

/**
* Asks the launcher to pin the shortcut, calling [onPinned] if and when it does.
*
Expand All @@ -90,7 +148,19 @@ object LaunchShortcut {
if (!isParasitic(context)) return false
val shortcut = build(context) ?: return false
return runCatching {
manager(context)?.requestPinShortcut(shortcut, callback(context, onPinned)) == true
val confirmed =
callback(context) {
// Recorded here rather than when the request is made, because the launcher
// may refuse or the reader may dismiss its dialog, and a launcher noted as
// holding a shortcut it never took would suppress the offer for good. Read
// again rather than captured: this runs after the launcher's own dialog,
// which is long enough for the default home to have changed.
currentLauncher(context)?.let {
ServiceLocator.settings.noteShortcutLauncher(it)
}
onPinned()
}
manager(context)?.requestPinShortcut(shortcut, confirmed) == true
}
.onFailure { logE("actions: pin shortcut request failed", it) }
.getOrDefault(false)
Expand Down Expand Up @@ -244,6 +314,9 @@ object LaunchShortcut {
/** The synthesised entry every package has, used as the shortcut's publishing activity on Q+. */
private const val APP_DETAILS_ACTIVITY = "android.app.AppDetailsActivity"

/** What CATEGORY_HOME resolves to when the device has several launchers and no default. */
private const val RESOLVER_PACKAGE = "android"

/** Held by the system and by nothing installable, so only the platform can confirm a pin. */
private const val CONFIRMATION_PERMISSION = "android.permission.CREATE_USERS"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.matrix.vector.manager.data.repository

import android.content.Context
import android.content.SharedPreferences
import java.time.LocalDate
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand Down Expand Up @@ -323,6 +324,77 @@ class SettingsRepository(context: Context) {
_launcherPromptDismissed.value = true
}

/**
* Which launchers are known to be holding a pinned Vector shortcut.
*
* The platform will not say. `ShortcutManager.getPinnedShortcuts` answers for *any* launcher at
* once — the pin flag lives on the shortcut, not on the pair — and the per-launcher sets are
* only readable by a caller that is itself the active launcher. So a device that pinned the
* shortcut, then installed a different launcher, is told it already has one while its home
* screen has nothing on it, which is what #883 reported.
*
* A set rather than a single package because pinning on a second launcher does not unpin the
* first, and someone who keeps two and switches between them should not be offered a shortcut
* they already have on both. What the set cannot represent is a shortcut *removed* from one of
* several launchers holding it: nothing tells us which one lost it, and the platform still
* reports the shortcut pinned. That row will read as done until the last copy is gone.
*/
fun shortcutLaunchers(): Set<String> =
prefs.getStringSet("shortcut_launchers", emptySet()).orEmpty().toSet()

fun noteShortcutLauncher(packageName: String) {
val known = shortcutLaunchers()
if (packageName in known) return
// A set of our own: `getStringSet` hands back the instance the preferences hold, which the
// platform documents as not ours to modify.
prefs.edit().putStringSet("shortcut_launchers", HashSet(known + packageName)).apply()
}

// --- the status badge's own hint ----------------------------------------------------------

/**
* How many times *today* the status badge was used to open System status.
*
* The badge is the only way to those settings, and nothing about a tick says so — #856. The
* header answers that by having the tick turn into a gear now and then, and this is what stops
* it: a reader who has opened the page several times today plainly knows where it is, and a gear
* that keeps appearing after that is noise on the one part of the header whose job is to report
* a state. How many is several is HomeViewModel's to say — this only counts.
*
* Counted per day rather than for good because the hint costs nothing to offer again and the
* knowledge does fade — and because a count that only ever grows would retire the hint on the
* strength of an afternoon spent on that page months ago. The day is stored beside the count and
* a stale one reads as zero, so no reset has to run at midnight.
*/
private val _statusBadgeOpens = MutableStateFlow(statusBadgeOpensToday())
val statusBadgeOpens: StateFlow<Int> = _statusBadgeOpens.asStateFlow()

fun noteStatusBadgeOpened() {
val today = LocalDate.now().toEpochDay()
// Against the stored day, not against the flow: a session left open across midnight holds
// yesterday's count in memory, and adding to it would carry it into today.
val next =
if (prefs.getLong("status_badge_day", 0L) == today) _statusBadgeOpens.value + 1 else 1
prefs.edit().putLong("status_badge_day", today).putInt("status_badge_opens", next).apply()
_statusBadgeOpens.value = next
}

/**
* Re-reads the count against today's date.
*
* Called when Home is opened, which is the only moment the hint can start running again, and is
* what lets a session that has crossed midnight — parasitically rare, since the host process is
* killed constantly, but free to handle — offer it afresh.
*/
fun refreshStatusBadgeOpens() {
_statusBadgeOpens.value = statusBadgeOpensToday()
}

private fun statusBadgeOpensToday(): Int =
if (prefs.getLong("status_badge_day", 0L) == LocalDate.now().toEpochDay())
prefs.getInt("status_badge_opens", 0)
else 0

// --- Logs ---

/**
Expand Down
Loading
Loading