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
13 changes: 13 additions & 0 deletions sheaf/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@
android:host="notifications"
android:path="/redeem" />
</intent-filter>
<!-- "Open Sheaf on phone" from the watch. The wear app fires this
via RemoteActivityHelper when the user is stuck on the watch's
"open on your phone to sign in" screen; it just brings the app
to the foreground (the URL carries no redeem params, so
captureRedemptionDeepLink ignores it). -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="sheaf"
android:host="open" />
</intent-filter>
<!-- Verified App Link for the canonical domain. sheaf.sh hosts
/.well-known/assetlinks.json, so autoVerify lets these open
the app directly with no browser/chooser. Self-hosted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ private fun FilePickSection(onPick: () -> Unit) {
Text("Choose your Ampersand export to get started.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onPick) { Text("Choose file") }
Text(
"In Ampersand, open Settings → Import & export → Export, and save the " +
".json file. Then select it here.",
"In Ampersand, open Settings → Import & export → Export your data to a JSON file (note: this is a different option to 'Export your data', which " +
"produces an incompatible file format), and save the .json file. Then select it here.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
Expand Down
2 changes: 2 additions & 0 deletions sheaf/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ wearCompose = "1.4.1"
wearTiles = "1.4.1"
wearComplications = "1.3.0"
playServicesWearable = "18.2.0"
wearRemoteInteractions = "1.0.0"
concurrentFutures = "1.1.0"
room = "2.6.1"
work = "2.9.1"
Expand Down Expand Up @@ -83,6 +84,7 @@ wear-tiles = { group = "androidx.wear.tiles", name = "tiles", version.ref = "wea
wear-tiles-material = { group = "androidx.wear.tiles", name = "tiles-material", version.ref = "wearTiles" }
wear-complications-data-source-ktx = { group = "androidx.wear.watchface", name = "watchface-complications-data-source-ktx", version.ref = "wearComplications" }
play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" }
wear-remote-interactions = { group = "androidx.wear", name = "wear-remote-interactions", version.ref = "wearRemoteInteractions" }
concurrent-futures = { group = "androidx.concurrent", name = "concurrent-futures-ktx", version.ref = "concurrentFutures" }
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
Expand Down
2 changes: 2 additions & 0 deletions sheaf/wear/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ dependencies {
// Wearable Data Layer
implementation(libs.play.services.wearable)
implementation(libs.kotlinx.coroutines.play.services)
// Opening the companion app on the phone from the watch
implementation(libs.wear.remote.interactions)

// Networking
implementation(libs.okhttp.logging)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,32 @@ class WearAuthManager(context: Context) {
!secureString("access_token").isNullOrBlank() &&
!secureString("base_url").isNullOrBlank()

private val _isAuthenticatedFlow = MutableStateFlow(isCredentialed())
val isAuthenticatedFlow: StateFlow<Boolean> = _isAuthenticatedFlow.asStateFlow()
// Auth state is a PROCESS-WIDE flow, not a per-instance one. The data-layer
// service, the activity, tiles and complications each build their own
// WearAuthManager over the same encrypted store; a per-instance flow meant
// the service applying a phone-pushed token updated only its own copy, so
// the login screen (observing the activity's copy) never saw it and the
// user had to leave and re-enter the screen. A shared flow means any
// instance's saveCredentials/clearCredentials updates the exact flow the UI
// collects. The signal-counter listener below stays as a belt for any write
// that bypasses this class.
val isAuthenticatedFlow: StateFlow<Boolean> = sharedAuthFlow.asStateFlow()

val isAuthenticated: Boolean get() = sharedAuthFlow.value

val isAuthenticated: Boolean get() = _isAuthenticatedFlow.value
init {
// Reconcile the shared flow with what's actually on disk for this
// (process-wide) store, so a freshly constructed manager reflects the
// current credential state.
sharedAuthFlow.value = isCredentialed()
}

// React to credential writes from other instances (e.g. the data-layer
// service handling a phone push) via the non-secret signal counter.
private val signalListener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == KEY_CREDS_VERSION) {
_isAuthenticatedFlow.value = isCredentialed()
sharedAuthFlow.value = isCredentialed()
}
}

Expand All @@ -83,7 +98,7 @@ class WearAuthManager(context: Context) {
// Applying credentials means we're signed in again, so drop any manual
// sign-out latch (a phone push or a manual watch login both land here).
signal.edit().putBoolean(KEY_MANUALLY_SIGNED_OUT, false).apply()
_isAuthenticatedFlow.value = true
sharedAuthFlow.value = true
notifyCredsChanged()
}

Expand Down Expand Up @@ -114,7 +129,7 @@ class WearAuthManager(context: Context) {
.remove("access_token")
.remove("refresh_token")
.apply()
_isAuthenticatedFlow.value = false
sharedAuthFlow.value = false
notifyCredsChanged()
}

Expand Down Expand Up @@ -185,6 +200,11 @@ class WearAuthManager(context: Context) {
}

private companion object {
// Process-wide auth state, shared across every WearAuthManager instance
// in the process so a credential write from one (e.g. the data-layer
// service) is observed by the others (e.g. the login screen).
val sharedAuthFlow = MutableStateFlow(false)

const val TAG = "WearAuthManager"
const val LEGACY_FILE = "wear_auth"
const val SECURE_FILE = "wear_auth_secure"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/** Result of a [WearStore.switchFront] attempt, so callers can word the toast. */
enum class SwitchOutcome {
/** The switch was applied (or captured offline and will land). */
SWITCHED,
/** An open front already had this exact member set; nothing to do. */
ALREADY_FRONTING,
/** A permanent client error the user should be told about. */
FAILED,
}

class WearStore(
val apiClient: WearApiClient,
private val context: Context,
Expand Down Expand Up @@ -137,22 +147,28 @@ class WearStore(
}
}

suspend fun switchFront(memberIds: List<String>, replaceFronts: Boolean? = null): Boolean {
suspend fun switchFront(memberIds: List<String>, replaceFronts: Boolean? = null): SwitchOutcome {
error.value = null
// Try the direct API path first. Common case; succeeds when the
// watch has its own network.
runCatching { apiClient.createFront(memberIds, replaceFronts) }
.onSuccess {
loadAll()
return true
return SwitchOutcome.SWITCHED
}
.onFailure { e ->
// A permanent client error (deleted member, bad payload) will
// never succeed on replay, so don't queue it or report success:
// surface it instead of silently dropping the switch on the floor.
if (e is WearApiException && isPermanentSwitchError(e.code)) {
error.value = "Couldn't switch front (error ${e.code})"
return false
if (e is WearApiException) {
// 409 = an open front already has this exact member set. The
// state the user wanted already holds, so this isn't a
// failure; say so specifically instead of "switch failed".
if (e.code == 409) return SwitchOutcome.ALREADY_FRONTING
// Any other permanent client error (deleted member, bad
// payload) will never succeed on replay, so don't queue it or
// report success: surface it instead of dropping the switch.
if (isPermanentSwitchError(e.code)) {
error.value = "Couldn't switch front (error ${e.code})"
return SwitchOutcome.FAILED
}
}
}
// Direct call failed (most often: watch is offline). Two
Expand All @@ -170,11 +186,10 @@ class WearStore(
if (!WearSwitchQueue.sendToPhone(context, queued)) {
WearSwitchQueue.enqueue(context, queued)
}
// Report success either way: the user pressed switch, the
// system has captured it, and it will land — surfacing a
// transient "network failed" they can't act on would be
// worse UX than the rare lost-on-floor case below.
return true
// Report a switch either way: the user pressed switch, the system has
// captured it, and it will land. Surfacing a transient "network failed"
// they can't act on would be worse UX than the rare lost-on-floor case.
return SwitchOutcome.SWITCHED
}

suspend fun createMember(name: String, displayName: String?, pronouns: String?): WearMember {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ fun SwitchScreen(navController: NavController) {
// one-shot override for this switch and doesn't change the saved default.
var endExisting by remember(endExistingDefault) { mutableStateOf(endExistingDefault) }
var isSwitching by remember { mutableStateOf(false) }
var switched by remember { mutableStateOf(false) }
// Non-null once a switch attempt resolved to a "done" state: the confirm
// text to flash before popping (either "Switched!" or "Already fronting").
var confirmText by remember { mutableStateOf<String?>(null) }

LaunchedEffect(switched) {
if (switched) {
LaunchedEffect(confirmText) {
if (confirmText != null) {
delay(1000)
navController.popBackStack()
}
Expand All @@ -90,10 +92,10 @@ fun SwitchScreen(navController: NavController) {
CircularProgressIndicator()
}
}
switched -> {
confirmText != null -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Switched!",
text = confirmText!!,
style = MaterialTheme.typography.title3,
color = MaterialTheme.colors.secondary,
)
Expand Down Expand Up @@ -187,9 +189,14 @@ fun SwitchScreen(navController: NavController) {
onClick = {
isSwitching = true
scope.launch {
val ok = store.switchFront(selected.toList(), endExisting)
val outcome = store.switchFront(selected.toList(), endExisting)
isSwitching = false
if (ok) switched = true
confirmText = when (outcome) {
systems.lupine.sheaf.wear.data.SwitchOutcome.SWITCHED -> "Switched!"
systems.lupine.sheaf.wear.data.SwitchOutcome.ALREADY_FRONTING -> "Already fronting"
// Failure: store.error drives the banner; stay on the screen.
systems.lupine.sheaf.wear.data.SwitchOutcome.FAILED -> null
}
}
},
// Mint-green commit accent so the action chip reads
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.wear.remote.interactions.RemoteActivityHelper
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
Expand All @@ -33,6 +40,27 @@ import systems.lupine.sheaf.wear.data.WearAuthManager
import systems.lupine.sheaf.wear.data.WearSettingsStore
import systems.lupine.sheaf.wear.data.WearStore

/**
* Ask the paired phone to open the Sheaf app. Uses RemoteActivityHelper (a
* system-mediated remote start, so it isn't blocked by the phone's background-
* activity-launch limits) with a `sheaf://open` deep link the phone app
* registers. Toasts if the phone can't be reached.
*/
private fun openSheafOnPhone(context: Context) {
val intent = Intent(Intent.ACTION_VIEW)
.addCategory(Intent.CATEGORY_BROWSABLE)
.setData(Uri.parse("sheaf://open"))
val future = RemoteActivityHelper(context).startRemoteActivity(intent)
future.addListener(
{
runCatching { future.get() }.onFailure {
Toast.makeText(context, "Couldn't reach your phone", Toast.LENGTH_SHORT).show()
}
},
ContextCompat.getMainExecutor(context),
)
}

val LocalWearStore = staticCompositionLocalOf<WearStore> { error("No WearStore") }
val LocalWearAuth = staticCompositionLocalOf<WearAuthManager> { error("No WearAuthManager") }
val LocalWearSettings = staticCompositionLocalOf<WearSettingsStore> { error("No WearSettingsStore") }
Expand Down Expand Up @@ -64,6 +92,7 @@ fun WearNavigation(
}

if (!isAuthenticated) {
val context = LocalContext.current
var showLogin by remember { mutableStateOf(false) }
if (showLogin) {
WearLoginScreen(
Expand All @@ -89,11 +118,19 @@ fun WearNavigation(
style = MaterialTheme.typography.body1,
)
}
item {
Chip(
label = { Text("Open on phone") },
onClick = { openSheafOnPhone(context) },
colors = ChipDefaults.primaryChipColors(),
modifier = Modifier.fillMaxWidth(),
)
}
item {
Chip(
label = { Text("Retry Sync") },
onClick = onRequestSync,
colors = ChipDefaults.primaryChipColors(),
colors = ChipDefaults.secondaryChipColors(),
modifier = Modifier.fillMaxWidth(),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,11 @@ class QuickSwitchTrampolineActivity : ComponentActivity() {
val store = WearStore(api, applicationContext)

lifecycleScope.launch {
val ok = store.switchFront(selected, replaceFronts = endExisting)
val msg = if (ok) "Switched to $name" else "Switch failed"
val msg = when (store.switchFront(selected, replaceFronts = endExisting)) {
systems.lupine.sheaf.wear.data.SwitchOutcome.SWITCHED -> "Switched to $name"
systems.lupine.sheaf.wear.data.SwitchOutcome.ALREADY_FRONTING -> "$name already fronting"
systems.lupine.sheaf.wear.data.SwitchOutcome.FAILED -> "Switch failed"
}
Toast.makeText(applicationContext, msg, Toast.LENGTH_SHORT).show()
// Always reset transient state on commit attempts. If the call
// failed the user can re-pick; leaving the selection sticky
Expand Down
Loading