From ad4ce6b15ff3ab91877b8957cfe7bffda75b32bf Mon Sep 17 00:00:00 2001 From: Ross Tomsic Date: Sun, 6 Sep 2026 18:48:13 -0400 Subject: [PATCH] Gate connections on notification access --- PROJECT_SPEC.md | 6 + .../threadline/ConnectionFormRetentionTest.kt | 198 +++++++++++++++++ .../main/java/dev/threadline/MainActivity.kt | 204 ++++++++++++++++-- .../threadline/service/SshSessionService.kt | 33 ++- .../service/SshSessionServiceTest.kt | 46 ++++ 5 files changed, 469 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/dev/threadline/service/SshSessionServiceTest.kt diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md index 3d6bab8..c3c4d25 100644 --- a/PROJECT_SPEC.md +++ b/PROJECT_SPEC.md @@ -106,6 +106,12 @@ Home has explicit task states: The selected Home task and non-secret connection draft survive Android saved-state restoration. Returning Home from the active session always starts at its active-session dashboard. +On Android versions that require notification permission, explain the ongoing session notification +and resolve permission before showing credential inputs. A denial leaves the non-secret connection +draft in place, offers one retry plus Android notification settings, and routes repeated denial to +Settings. Recheck permission when the app resumes. Never prepare or start SSH while Threadline's +required session notification is unavailable. + A saved host profile contains: - Display name diff --git a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt index fff80e3..822713a 100644 --- a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt +++ b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt @@ -928,6 +928,204 @@ class ConnectionFormRetentionTest { compose.onNodeWithTag(ConnectionFormTags.HOSTNAME).assertIsFocused() } + @Test + fun notificationPermissionStateTracksFirstAndRepeatedDenial() { + assertEquals( + SessionNotificationPermissionState.REQUESTABLE, + sessionNotificationPermissionState(granted = false, denialCount = 0), + ) + assertEquals( + SessionNotificationPermissionState.DENIED, + sessionNotificationPermissionState(granted = false, denialCount = 1), + ) + assertEquals( + SessionNotificationPermissionState.SETTINGS_REQUIRED, + sessionNotificationPermissionState(granted = false, denialCount = 2), + ) + assertEquals( + SessionNotificationPermissionState.GRANTED, + sessionNotificationPermissionState(granted = true, denialCount = 2), + ) + } + + @Test + fun notificationPermissionIsExplainedBeforeCredentialEntryAndFirstGrantUnlocksForm() { + val permissionState = mutableStateOf(SessionNotificationPermissionState.REQUESTABLE) + var requestCount = 0 + var preparedCount = 0 + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.fixtureDefaults(), + onDraftChange = {}, + sessionError = null, + notificationPermissionState = permissionState.value, + onRequestNotificationPermission = { requestCount += 1 }, + onPrepared = { + preparedCount += 1 + it.credential.clear() + true + }, + ) + } + } + + compose.onNodeWithText("Keep active SSH sessions visible") + .performScrollTo() + .assertIsDisplayed() + compose.onNodeWithText( + "Allow session notifications before entering a password or passphrase.", + substring = true, + ).assertIsDisplayed() + compose.onNodeWithTag(ConnectionFormTags.PASSWORD).assertDoesNotExist() + compose.onNodeWithTag(ConnectionFormTags.CONNECT).assertDoesNotExist() + + compose.onNodeWithTag(ConnectionFormTags.REQUEST_NOTIFICATION_PERMISSION) + .performClick() + compose.runOnIdle { + assertEquals(1, requestCount) + assertEquals(0, preparedCount) + permissionState.value = SessionNotificationPermissionState.GRANTED + } + + compose.onNodeWithTag(ConnectionFormTags.PASSWORD) + .performScrollTo() + .assertIsDisplayed() + compose.onNodeWithTag(ConnectionFormTags.CONNECT).assertExists() + } + + @Test + fun notificationDenialAllowsRetryAndRepeatedDenialRoutesToSettings() { + val draft = mutableStateOf(ConnectionFormDraft.fixtureDefaults()) + val permissionState = mutableStateOf(SessionNotificationPermissionState.REQUESTABLE) + var requestCount = 0 + var settingsOpenCount = 0 + var preparedCount = 0 + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + notificationPermissionState = permissionState.value, + onRequestNotificationPermission = { requestCount += 1 }, + onOpenNotificationSettings = { settingsOpenCount += 1 }, + onPrepared = { + preparedCount += 1 + it.credential.clear() + true + }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.HOSTNAME) + .performTextReplacement("changed.example") + compose.onNodeWithTag(ConnectionFormTags.REQUEST_NOTIFICATION_PERMISSION) + .performScrollTo() + .performClick() + compose.runOnIdle { + permissionState.value = SessionNotificationPermissionState.DENIED + } + compose.onNodeWithText("Notification access is still off") + .performScrollTo() + .assertIsDisplayed() + compose.onNodeWithTag(ConnectionFormTags.PASSWORD).assertDoesNotExist() + compose.onNodeWithTag(ConnectionFormTags.CONNECT).assertDoesNotExist() + compose.onNodeWithTag(ConnectionFormTags.HOSTNAME) + .assertEditableTextEquals("changed.example") + compose.onNodeWithTag(ConnectionFormTags.OPEN_NOTIFICATION_SETTINGS) + .performScrollTo() + .performClick() + compose.onNodeWithTag(ConnectionFormTags.REQUEST_NOTIFICATION_PERMISSION) + .performScrollTo() + .performClick() + + compose.runOnIdle { + assertEquals(2, requestCount) + assertEquals(1, settingsOpenCount) + assertEquals(0, preparedCount) + permissionState.value = SessionNotificationPermissionState.SETTINGS_REQUIRED + } + compose.onNodeWithTag(ConnectionFormTags.REQUEST_NOTIFICATION_PERMISSION) + .assertDoesNotExist() + compose.onNodeWithTag(ConnectionFormTags.OPEN_NOTIFICATION_SETTINGS) + .performScrollTo() + .performClick() + compose.runOnIdle { + assertEquals(2, settingsOpenCount) + assertEquals(0, preparedCount) + } + } + + @Test + fun settingsRecoveryRevealsAnEmptyCredentialField() { + val permissionState = mutableStateOf(SessionNotificationPermissionState.GRANTED) + var settingsOpenCount = 0 + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.fixtureDefaults(), + onDraftChange = {}, + sessionError = null, + notificationPermissionState = permissionState.value, + onOpenNotificationSettings = { settingsOpenCount += 1 }, + onPrepared = { true }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.PASSWORD) + .performTextReplacement("session-only") + compose.runOnIdle { + permissionState.value = SessionNotificationPermissionState.SETTINGS_REQUIRED + } + compose.onNodeWithTag(ConnectionFormTags.PASSWORD).assertDoesNotExist() + compose.onNodeWithTag(ConnectionFormTags.OPEN_NOTIFICATION_SETTINGS) + .performScrollTo() + .performClick() + compose.runOnIdle { + assertEquals(1, settingsOpenCount) + permissionState.value = SessionNotificationPermissionState.GRANTED + } + + compose.onNodeWithTag(ConnectionFormTags.PASSWORD) + .performScrollTo() + .assertEditableTextEquals("") + } + + @Test + fun alreadyGrantedPermissionConnectsWithoutShowingTheGate() { + var preparedCount = 0 + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.fixtureDefaults(), + onDraftChange = {}, + sessionError = null, + notificationPermissionState = SessionNotificationPermissionState.GRANTED, + onPrepared = { + preparedCount += 1 + it.credential.clear() + true + }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.NOTIFICATION_PERMISSION).assertDoesNotExist() + compose.onNodeWithTag(ConnectionFormTags.PASSWORD) + .performScrollTo() + .performTextReplacement("session-only") + compose.onNodeWithTag(ConnectionFormTags.CONNECT) + .performScrollTo() + .performClick() + compose.waitForIdle() + + compose.runOnIdle { assertEquals(1, preparedCount) } + compose.onNodeWithTag(ConnectionFormTags.PASSWORD).assertEditableTextEquals("") + } + @Test fun notificationFailureOnlyOpensSettingsAfterTheUserAction() { var settingsOpenCount = 0 diff --git a/app/src/main/java/dev/threadline/MainActivity.kt b/app/src/main/java/dev/threadline/MainActivity.kt index a1910e2..9d91a03 100644 --- a/app/src/main/java/dev/threadline/MainActivity.kt +++ b/app/src/main/java/dev/threadline/MainActivity.kt @@ -5,7 +5,6 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -49,6 +48,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -72,8 +72,9 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.threadline.core.model.ConnectionRequest import dev.threadline.core.model.HostEndpoint @@ -95,6 +96,7 @@ import dev.threadline.data.profile.SavedHostProfile import dev.threadline.data.transcript.SavedTranscriptSession import dev.threadline.data.transcript.SavedTranscriptSessionSummary import dev.threadline.service.SshSessionService +import dev.threadline.service.hasSessionNotificationPermission import java.io.ByteArrayOutputStream import java.text.DateFormat import java.util.Date @@ -130,6 +132,28 @@ internal enum class HomeTask { SECURITY, } +internal enum class SessionNotificationPermissionState { + GRANTED, + REQUESTABLE, + DENIED, + SETTINGS_REQUIRED, + ; + + val allowsCredentialEntry: Boolean + get() = this == GRANTED +} + +internal fun sessionNotificationPermissionState( + granted: Boolean, + denialCount: Int, +): SessionNotificationPermissionState = when { + granted -> SessionNotificationPermissionState.GRANTED + denialCount >= REPEATED_NOTIFICATION_PERMISSION_DENIAL_COUNT -> + SessionNotificationPermissionState.SETTINGS_REQUIRED + denialCount > 0 -> SessionNotificationPermissionState.DENIED + else -> SessionNotificationPermissionState.REQUESTABLE +} + internal data class ConnectionFormDraft( val displayName: String, val hostname: String, @@ -221,6 +245,9 @@ internal object ConnectionFormTags { const val ERROR_ACTION = "connection-error-action" const val VALIDATION_ERROR = "connection-validation-error" const val PREPARATION_ERROR = "connection-preparation-error" + const val NOTIFICATION_PERMISSION = "connection-notification-permission" + const val REQUEST_NOTIFICATION_PERMISSION = "connection-request-notification-permission" + const val OPEN_NOTIFICATION_SETTINGS = "connection-open-notification-settings" const val CHOOSE_PRIVATE_KEY = "connection-choose-private-key" const val HELP = "connection-help" } @@ -261,6 +288,23 @@ private fun ThreadlineApp() { var diagnosticGeneratedAtMillis by remember { mutableStateOf(null) } val diagnosticEnvironment = remember(context) { androidDiagnosticEnvironment(context) } val openDiagnostics = { diagnosticGeneratedAtMillis = System.currentTimeMillis() } + val notificationPermissionPreferences = remember(context) { + context.getSharedPreferences( + NOTIFICATION_PERMISSION_PREFERENCES, + Context.MODE_PRIVATE, + ) + } + var notificationPermissionGranted by remember { + mutableStateOf(hasSessionNotificationPermission(context)) + } + var notificationPermissionDenialCount by remember { + mutableIntStateOf( + notificationPermissionPreferences.getInt( + NOTIFICATION_PERMISSION_DENIAL_COUNT, + 0, + ), + ) + } LaunchedEffect(state is SessionState.Connected) { if (state !is SessionState.Connected) { @@ -271,10 +315,33 @@ private fun ThreadlineApp() { val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> + notificationPermissionGranted = granted if (granted) { - startSessionService(context) + notificationPermissionDenialCount = 0 + notificationPermissionPreferences.edit() + .remove(NOTIFICATION_PERMISSION_DENIAL_COUNT) + .apply() } else { - manager.cancelPrepared(SessionError.NotificationPermissionRequired) + notificationPermissionDenialCount = + (notificationPermissionDenialCount + 1).coerceAtMost( + REPEATED_NOTIFICATION_PERMISSION_DENIAL_COUNT, + ) + notificationPermissionPreferences.edit() + .putInt( + NOTIFICATION_PERMISSION_DENIAL_COUNT, + notificationPermissionDenialCount, + ) + .apply() + } + } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + notificationPermissionGranted = hasSessionNotificationPermission(context) + if (notificationPermissionGranted && notificationPermissionDenialCount > 0) { + notificationPermissionDenialCount = 0 + notificationPermissionPreferences.edit() + .remove(NOTIFICATION_PERMISSION_DENIAL_COUNT) + .apply() } } @@ -309,7 +376,9 @@ private fun ThreadlineApp() { -> HostForm( draft = connectionDraft, onDraftChange = { connectionDraft = it }, - sessionError = (current as? SessionState.Failed)?.error, + sessionError = (current as? SessionState.Failed) + ?.error + ?.takeUnless { it == SessionError.NotificationPermissionRequired }, activeSessionDisplayName = (current as? SessionState.Connected)?.displayName, connectionEnabled = current !is SessionState.Connected, initialTask = if (current is SessionState.Failed) { @@ -340,21 +409,17 @@ private fun ThreadlineApp() { onOpenIntroduction = { showIntroduction = true }, onOpenDiagnostics = openDiagnostics, onOpenNotificationSettings = { openNotificationSettings(context) }, + notificationPermissionState = sessionNotificationPermissionState( + granted = notificationPermissionGranted, + denialCount = notificationPermissionDenialCount, + ), + onRequestNotificationPermission = { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + }, onPrepared = prepared@{ request -> if (!manager.prepareConnection(request)) return@prepared false showConnectedSession = true - - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) != PackageManager.PERMISSION_GRANTED - ) { - permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } else { - startSessionService(context) - } + startSessionService(context) true }, ) @@ -447,6 +512,90 @@ private fun openNotificationSettings(context: Context) { context.startActivity(intent) } +@Composable +private fun NotificationPermissionCard( + state: SessionNotificationPermissionState, + onRequestPermission: () -> Unit, + onOpenSettings: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.NOTIFICATION_PERMISSION) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + if (state == SessionNotificationPermissionState.REQUESTABLE) { + "Keep active SSH sessions visible" + } else { + "Notification access is still off" + }, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.semantics { heading() }, + ) + Text( + when (state) { + SessionNotificationPermissionState.REQUESTABLE -> + "Threadline uses an ongoing notification so an active SSH session stays " + + "visible and can be disconnected. Allow session notifications " + + "before entering a password or passphrase." + SessionNotificationPermissionState.DENIED -> + "No connection was started, and your server details are still here. " + + "Try the permission again or allow it in Android settings." + SessionNotificationPermissionState.SETTINGS_REQUIRED -> + "No connection was started, and your server details are still here. " + + "Allow session notifications in Android settings to continue." + SessionNotificationPermissionState.GRANTED -> "" + }, + ) + when (state) { + SessionNotificationPermissionState.REQUESTABLE -> Button( + onClick = onRequestPermission, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.REQUEST_NOTIFICATION_PERMISSION), + ) { + Text("Allow session notifications") + } + + SessionNotificationPermissionState.DENIED -> { + Button( + onClick = onRequestPermission, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.REQUEST_NOTIFICATION_PERMISSION), + ) { + Text("Try permission again") + } + TextButton( + onClick = onOpenSettings, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.OPEN_NOTIFICATION_SETTINGS), + ) { + Text("Open notification settings") + } + } + + SessionNotificationPermissionState.SETTINGS_REQUIRED -> Button( + onClick = onOpenSettings, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.OPEN_NOTIFICATION_SETTINGS), + ) { + Text("Open notification settings") + } + + SessionNotificationPermissionState.GRANTED -> Unit + } + } + } +} + @Composable internal fun HostForm( draft: ConnectionFormDraft, @@ -510,6 +659,9 @@ internal fun HostForm( onOpenIntroduction: () -> Unit = {}, onOpenDiagnostics: () -> Unit = {}, onOpenNotificationSettings: () -> Unit = {}, + notificationPermissionState: SessionNotificationPermissionState = + SessionNotificationPermissionState.GRANTED, + onRequestNotificationPermission: () -> Unit = {}, onPrepared: (ConnectionRequest) -> Boolean, ) { val context = androidx.compose.ui.platform.LocalContext.current @@ -568,6 +720,12 @@ internal fun HostForm( connectionPreparationError = null } + LaunchedEffect(notificationPermissionState) { + if (!notificationPermissionState.allowsCredentialEntry) { + clearSessionCredentialInputs() + } + } + fun clearValidationError(field: ConnectionValidationField) { if (validationError?.field == field) validationError = null connectionPreparationError = null @@ -934,6 +1092,15 @@ internal fun HostForm( style = MaterialTheme.typography.bodySmall, ) + if (!notificationPermissionState.allowsCredentialEntry) { + NotificationPermissionCard( + state = notificationPermissionState, + onRequestPermission = onRequestNotificationPermission, + onOpenSettings = onOpenNotificationSettings, + ) + return@Column + } + Text( "Authentication", style = MaterialTheme.typography.labelLarge, @@ -2065,3 +2232,6 @@ private class ClearingByteArrayOutputStream : ByteArrayOutputStream() { } private const val MAX_PRIVATE_KEY_BYTES = 1024 * 1024 +private const val NOTIFICATION_PERMISSION_PREFERENCES = "notification_permission" +private const val NOTIFICATION_PERMISSION_DENIAL_COUNT = "denial_count" +private const val REPEATED_NOTIFICATION_PERMISSION_DENIAL_COUNT = 2 diff --git a/app/src/main/java/dev/threadline/service/SshSessionService.kt b/app/src/main/java/dev/threadline/service/SshSessionService.kt index b2dc984..7e24530 100644 --- a/app/src/main/java/dev/threadline/service/SshSessionService.kt +++ b/app/src/main/java/dev/threadline/service/SshSessionService.kt @@ -1,5 +1,6 @@ package dev.threadline.service +import android.Manifest import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel @@ -8,6 +9,7 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder @@ -64,7 +66,17 @@ class SshSessionService : Service() { private fun startPreparedConnection() { handlesSessionCommand = true - if (!SessionRuntime.manager.connectPrepared()) { + if ( + !connectPreparedWithNotificationPermission( + permissionGranted = hasSessionNotificationPermission(this), + onPermissionMissing = { + SessionRuntime.manager.cancelPrepared( + SessionError.NotificationPermissionRequired, + ) + }, + connectPrepared = SessionRuntime.manager::connectPrepared, + ) + ) { handlesSessionCommand = false stopSelf() return @@ -184,3 +196,22 @@ class SshSessionService : Service() { } } } + +internal fun hasSessionNotificationPermission(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + +internal fun connectPreparedWithNotificationPermission( + permissionGranted: Boolean, + onPermissionMissing: () -> Unit, + connectPrepared: () -> Boolean, +): Boolean { + if (!permissionGranted) { + onPermissionMissing() + return false + } + return connectPrepared() +} diff --git a/app/src/test/java/dev/threadline/service/SshSessionServiceTest.kt b/app/src/test/java/dev/threadline/service/SshSessionServiceTest.kt new file mode 100644 index 0000000..0a33613 --- /dev/null +++ b/app/src/test/java/dev/threadline/service/SshSessionServiceTest.kt @@ -0,0 +1,46 @@ +package dev.threadline.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SshSessionServiceTest { + @Test + fun `missing notification permission rejects prepared connection before SSH starts`() { + var permissionFailureCount = 0 + var connectionStartCount = 0 + + val started = connectPreparedWithNotificationPermission( + permissionGranted = false, + onPermissionMissing = { permissionFailureCount += 1 }, + connectPrepared = { + connectionStartCount += 1 + true + }, + ) + + assertFalse(started) + assertEquals(1, permissionFailureCount) + assertEquals(0, connectionStartCount) + } + + @Test + fun `granted notification permission allows prepared connection to start`() { + var permissionFailureCount = 0 + var connectionStartCount = 0 + + val started = connectPreparedWithNotificationPermission( + permissionGranted = true, + onPermissionMissing = { permissionFailureCount += 1 }, + connectPrepared = { + connectionStartCount += 1 + true + }, + ) + + assertTrue(started) + assertEquals(0, permissionFailureCount) + assertEquals(1, connectionStartCount) + } +}