diff --git a/README.md b/README.md index e22801a..4f88b40 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,23 @@ with a packet capture. ## TV settings screen +### First run + +The first launch after installing opens a three-step setup instead of the settings screen: + +1. **Welcome** explains what the app does and shows the address this TV listens on. +2. **Permission** walks through the overlay permission. When the TV offers a system page for it, a + "Try system settings" button opens it; otherwise the adb command is shown on screen. The step + re-checks every few seconds and moves on by itself once the permission is granted. It can be + skipped, but every following screen then states that TibeePost is unusable until the permission + is granted, and the settings screen keeps saying so on every launch. +3. **Test** sends a real notification through the local server so you can see a card appear before + any remote client is involved. + +The setup runs once. A "Run setup again" row at the bottom of the settings screen brings it back. + +### Settings + Open TibeePost from the launcher. Everything is reachable with the D-pad; there are no text fields. @@ -342,6 +359,7 @@ fields. - **Default sound** None or chime, with a Preview button. - **Default card width, duration, dim, position, background, text color, accent** Press left and right on a row to step the value. These become the defaults for fields a `POST` omits. +- **Run setup again** Replays the first-run flow. ## Web client diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/MainActivity.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/MainActivity.kt index 53f5f4b..230ebed 100644 --- a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/MainActivity.kt +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/MainActivity.kt @@ -3,16 +3,32 @@ package com.zorenkonte.tibeepost import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import com.zorenkonte.tibeepost.service.ServiceStarter +import com.zorenkonte.tibeepost.settings.Settings import com.zorenkonte.tibeepost.ui.SettingsScreen import com.zorenkonte.tibeepost.ui.TibeeTheme +import com.zorenkonte.tibeepost.ui.onboarding.OnboardingScreen +import com.zorenkonte.tibeepost.ui.rememberSettingsState class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ServiceStarter.start(this) + val setupPending = !Settings(this).onboardingDone setContent { - TibeeTheme { SettingsScreen() } + TibeeTheme { + val state = rememberSettingsState() + var showSetup by rememberSaveable { mutableStateOf(setupPending) } + if (showSetup) { + OnboardingScreen(state, onFinished = { showSetup = false }) + } else { + SettingsScreen(state, onRerunSetup = { showSetup = true }) + } + } } } } diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/settings/Settings.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/settings/Settings.kt index 27eece5..403c4f1 100644 --- a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/settings/Settings.kt +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/settings/Settings.kt @@ -15,6 +15,10 @@ class Settings(context: Context) { get() = prefs.getString(SettingsKeys.TOKEN, "") ?: "" set(value) = prefs.edit().putString(SettingsKeys.TOKEN, value).apply() + var onboardingDone: Boolean + get() = prefs.getBoolean(SettingsKeys.ONBOARDING_DONE, false) + set(value) = prefs.edit().putBoolean(SettingsKeys.ONBOARDING_DONE, value).apply() + var autostart: Boolean get() = prefs.getBoolean(SettingsKeys.AUTOSTART, true) set(value) = prefs.edit().putBoolean(SettingsKeys.AUTOSTART, value).apply() diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/settings/SettingsKeys.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/settings/SettingsKeys.kt index 5765365..9562862 100644 --- a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/settings/SettingsKeys.kt +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/settings/SettingsKeys.kt @@ -12,4 +12,5 @@ object SettingsKeys { const val ACCENT = "default_accent" const val POSITION = "default_position" const val SOUND = "default_sound" + const val ONBOARDING_DONE = "onboarding_done" } diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/SettingsScreen.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/SettingsScreen.kt index 3bc0a65..ac1e1c9 100644 --- a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/SettingsScreen.kt +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/SettingsScreen.kt @@ -31,7 +31,7 @@ import com.zorenkonte.tibeepost.sound.ChimePlayer import kotlinx.coroutines.launch @Composable -fun SettingsScreen(state: SettingsState = rememberSettingsState()) { +fun SettingsScreen(state: SettingsState = rememberSettingsState(), onRerunSetup: () -> Unit = {}) { val context = LocalContext.current val chime = remember { ChimePlayer(context) } DisposableEffect(chime) { onDispose { chime.release() } } @@ -139,6 +139,17 @@ fun SettingsScreen(state: SettingsState = rememberSettingsState()) { decoration = { ColorSwatch(state.accent) }, ) } + item { + ListItem( + selected = false, + onClick = { + state.resetOnboarding() + onRerunSetup() + }, + headlineContent = { Text(stringResource(R.string.row_rerun_setup), style = MaterialTheme.typography.titleLarge) }, + supportingContent = { Text(stringResource(R.string.row_rerun_setup_body), style = MaterialTheme.typography.bodyMedium) }, + ) + } } } } diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/SettingsState.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/SettingsState.kt index 4fb7827..3fdba6f 100644 --- a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/SettingsState.kt +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/SettingsState.kt @@ -63,6 +63,14 @@ class SettingsState(context: Context) { fun stopObservingPreferences() = settings.stopObserving(preferenceListener) + fun completeOnboarding() { + settings.onboardingDone = true + } + + fun resetOnboarding() { + settings.onboardingDone = false + } + fun generateToken() { settings.token = TokenGenerator.generate() } diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/DoneStep.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/DoneStep.kt new file mode 100644 index 0000000..6ca7b6e --- /dev/null +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/DoneStep.kt @@ -0,0 +1,37 @@ +package com.zorenkonte.tibeepost.ui.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.zorenkonte.tibeepost.R +import com.zorenkonte.tibeepost.ui.SettingsState + +@Composable +fun DoneStep(state: SettingsState, primaryFocus: FocusRequester, onFinish: () -> Unit) { + val usable = state.overlayPermitted + val address = "http://${state.ipAddress ?: stringResource(R.string.status_no_network)}:${state.port}" + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(if (usable) R.string.setup_done_title else R.string.setup_done_unusable_title), + style = MaterialTheme.typography.displayMedium, + ) + if (!usable) UnusableWarning(packageName = state.packageName) + Text(stringResource(R.string.setup_done_address), style = MaterialTheme.typography.titleMedium, color = Color(0xFF9CA3AF)) + Text(address, style = MaterialTheme.typography.displayMedium.copy(fontSize = 52.sp, lineHeight = 60.sp)) + Text(stringResource(R.string.setup_done_body), style = MaterialTheme.typography.bodyLarge) + Button(onClick = onFinish, modifier = Modifier.focusRequester(primaryFocus)) { + Text(stringResource(if (usable) R.string.setup_finish else R.string.setup_finish_anyway)) + } + } +} diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/OnboardingScreen.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..b4078b4 --- /dev/null +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,95 @@ +package com.zorenkonte.tibeepost.ui.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.zorenkonte.tibeepost.R +import com.zorenkonte.tibeepost.ui.SettingsState + +@Composable +fun OnboardingScreen(state: SettingsState, onFinished: () -> Unit) { + var step by rememberSaveable { mutableStateOf(OnboardingStep.WELCOME) } + var permissionSkipped by rememberSaveable { mutableStateOf(false) } + val primaryFocus = remember { FocusRequester() } + + LaunchedEffect(step) { primaryFocus.requestFocus() } + + val permittedOnEntry = remember(step) { state.overlayPermitted } + LaunchedEffect(step, state.overlayPermitted) { + if (step == OnboardingStep.PERMISSION && !permittedOnEntry && state.overlayPermitted) { + permissionSkipped = false + step = OnboardingStep.TEST + } + } + + Box( + Modifier + .fillMaxSize() + .background(Color(0xFF0B1020)) + .padding(horizontal = 96.dp, vertical = 56.dp), + contentAlignment = Alignment.CenterStart, + ) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(20.dp)) { + if (step != OnboardingStep.DONE) { + Text( + text = stringResource(R.string.setup_step, step.number), + style = MaterialTheme.typography.titleMedium, + color = Color(0xFF9CA3AF), + ) + } + when (step) { + OnboardingStep.WELCOME -> WelcomeStep( + state = state, + primaryFocus = primaryFocus, + onContinue = { step = OnboardingStep.PERMISSION }, + ) + OnboardingStep.PERMISSION -> PermissionStep( + state = state, + primaryFocus = primaryFocus, + onContinue = { + permissionSkipped = false + step = OnboardingStep.TEST + }, + onSkip = { + permissionSkipped = true + step = OnboardingStep.TEST + }, + ) + OnboardingStep.TEST -> TestStep( + state = state, + permissionMissing = permissionSkipped && !state.overlayPermitted, + primaryFocus = primaryFocus, + onBackToPermission = { step = OnboardingStep.PERMISSION }, + onContinue = { step = OnboardingStep.DONE }, + ) + OnboardingStep.DONE -> DoneStep( + state = state, + primaryFocus = primaryFocus, + onFinish = { + state.completeOnboarding() + onFinished() + }, + ) + } + } + } +} diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/OnboardingStep.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/OnboardingStep.kt new file mode 100644 index 0000000..f17e67c --- /dev/null +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/OnboardingStep.kt @@ -0,0 +1,8 @@ +package com.zorenkonte.tibeepost.ui.onboarding + +enum class OnboardingStep(val number: Int) { + WELCOME(1), + PERMISSION(2), + TEST(3), + DONE(3), +} diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/PermissionStep.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/PermissionStep.kt new file mode 100644 index 0000000..acea7f0 --- /dev/null +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/PermissionStep.kt @@ -0,0 +1,76 @@ +package com.zorenkonte.tibeepost.ui.onboarding + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.zorenkonte.tibeepost.R +import com.zorenkonte.tibeepost.ui.SettingsState + +@Composable +fun PermissionStep( + state: SettingsState, + primaryFocus: FocusRequester, + onContinue: () -> Unit, + onSkip: () -> Unit, +) { + if (state.overlayPermitted) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(stringResource(R.string.setup_permission_granted_title), style = MaterialTheme.typography.displayMedium) + Text(stringResource(R.string.setup_permission_granted_body), style = MaterialTheme.typography.bodyLarge) + Button(onClick = onContinue, modifier = Modifier.focusRequester(primaryFocus)) { + Text(stringResource(R.string.setup_continue)) + } + } + return + } + + val context = LocalContext.current + val systemSettings = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${state.packageName}")) + val canOpenSystemSettings = systemSettings.resolveActivity(context.packageManager) != null + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(stringResource(R.string.setup_permission_title), style = MaterialTheme.typography.displayMedium) + Text(stringResource(R.string.setup_permission_body), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.setup_permission_system), style = MaterialTheme.typography.bodyLarge) + Text( + text = "adb shell appops set ${state.packageName} SYSTEM_ALERT_WINDOW allow", + style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace), + color = Color(0xFFFDE68A), + ) + Text( + text = stringResource(R.string.setup_permission_recheck), + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF9CA3AF), + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (canOpenSystemSettings) { + Button( + onClick = { context.startActivity(systemSettings) }, + modifier = Modifier.focusRequester(primaryFocus), + ) { + Text(stringResource(R.string.overlay_open_settings)) + } + Button(onClick = onSkip) { Text(stringResource(R.string.setup_skip)) } + } else { + Button(onClick = onSkip, modifier = Modifier.focusRequester(primaryFocus)) { + Text(stringResource(R.string.setup_skip)) + } + } + } + } +} diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/TestStep.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/TestStep.kt new file mode 100644 index 0000000..596d80e --- /dev/null +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/TestStep.kt @@ -0,0 +1,56 @@ +package com.zorenkonte.tibeepost.ui.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.zorenkonte.tibeepost.R +import com.zorenkonte.tibeepost.ui.SettingsState +import com.zorenkonte.tibeepost.ui.TestNotificationSender +import kotlinx.coroutines.launch + +@Composable +fun TestStep( + state: SettingsState, + permissionMissing: Boolean, + primaryFocus: FocusRequester, + onBackToPermission: () -> Unit, + onContinue: () -> Unit, +) { + val scope = rememberCoroutineScope() + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (permissionMissing) UnusableWarning(packageName = null) + Text(stringResource(R.string.setup_test_title), style = MaterialTheme.typography.displayMedium) + Text(stringResource(R.string.setup_test_body), style = MaterialTheme.typography.bodyLarge) + if (state.testResult.isNotEmpty()) { + Text( + text = if (permissionMissing) stringResource(R.string.setup_test_blocked) else state.testResult, + style = MaterialTheme.typography.titleMedium, + color = if (permissionMissing) Color(0xFFFCA5A5) else Color(0xFF34D399), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { scope.launch { state.testResult = TestNotificationSender.send(state.token) } }, + modifier = Modifier.focusRequester(primaryFocus), + ) { + Text(stringResource(R.string.setup_test_send)) + } + Button(onClick = onContinue) { Text(stringResource(R.string.setup_continue)) } + if (permissionMissing) { + Button(onClick = onBackToPermission) { Text(stringResource(R.string.setup_back_to_permission)) } + } + } + } +} diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/UnusableWarning.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/UnusableWarning.kt new file mode 100644 index 0000000..160bec1 --- /dev/null +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/UnusableWarning.kt @@ -0,0 +1,41 @@ +package com.zorenkonte.tibeepost.ui.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.zorenkonte.tibeepost.R + +@Composable +fun UnusableWarning(packageName: String?, modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxWidth() + .background(Color(0xFF3F1D1D), RoundedCornerShape(12.dp)) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(R.string.setup_unusable), + style = MaterialTheme.typography.titleLarge, + color = Color(0xFFFCA5A5), + ) + if (packageName != null) { + Text( + text = "adb shell appops set $packageName SYSTEM_ALERT_WINDOW allow", + style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace), + color = Color(0xFFFDE68A), + ) + } + } +} diff --git a/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/WelcomeStep.kt b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/WelcomeStep.kt new file mode 100644 index 0000000..9790729 --- /dev/null +++ b/tv/app/src/main/kotlin/com/zorenkonte/tibeepost/ui/onboarding/WelcomeStep.kt @@ -0,0 +1,29 @@ +package com.zorenkonte.tibeepost.ui.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.zorenkonte.tibeepost.R +import com.zorenkonte.tibeepost.ui.SettingsState + +@Composable +fun WelcomeStep(state: SettingsState, primaryFocus: FocusRequester, onContinue: () -> Unit) { + val address = "http://${state.ipAddress ?: stringResource(R.string.status_no_network)}:${state.port}" + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(stringResource(R.string.setup_welcome_title), style = MaterialTheme.typography.displayMedium) + Text(stringResource(R.string.setup_welcome_line1), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.setup_welcome_line2, address), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.setup_welcome_line3), style = MaterialTheme.typography.bodyLarge) + Button(onClick = onContinue, modifier = Modifier.focusRequester(primaryFocus)) { + Text(stringResource(R.string.setup_continue)) + } + } +} diff --git a/tv/app/src/main/res/values/strings.xml b/tv/app/src/main/res/values/strings.xml index 63e210a..7c3b095 100644 --- a/tv/app/src/main/res/values/strings.xml +++ b/tv/app/src/main/res/values/strings.xml @@ -17,7 +17,7 @@ Overlay permission granted missing - Cards cannot be drawn yet + TibeePost is unusable until this permission is granted This TV has no settings page for the overlay permission. Connect with adb and run: Try system settings Send test notification @@ -39,4 +39,31 @@ Default background Default text color Default accent + Step %1$d of 3 + Continue + Welcome to TibeePost + Anything on your network can put a notification on this TV with a single HTTP request: a script, a shop system, a smart home hub, or the TibeePost web client. + This TV listens at %1$s. Write it down or leave the app open; it is shown on the settings screen too. + Cards are drawn over whatever is playing. Android only allows that after you grant the "display over other apps" permission, which is the next step. + Allow TibeePost to draw over other apps + Without this permission the server still answers requests, but no card can ever appear, so the app is unusable. Grant it now if you can. + Press Try system settings if the button is shown and toggle TibeePost on. If your TV has no such page, connect a computer with adb on the same network and run the command below. + This screen checks again every few seconds and moves on by itself once the permission is granted. + Overlay permission granted + TibeePost can draw cards over other apps. Next, send yourself a test card. + Skip for now + Back to permission + Send yourself a test card + This posts a notification to the server running on this TV, exactly like a remote client would. With the permission granted, a card appears over this screen for a few seconds and the chime plays. + Send test card + The server accepted the request, but no card could be drawn because the overlay permission is missing. + Cards cannot be shown. The overlay permission is missing, so TibeePost is unusable until it is granted. + You are all set + Setup finished, but TibeePost is not usable yet + Send notifications to + The settings screen shows this address, lets you require a bearer token, and sets the default look, sound and duration for cards that do not specify their own. + Finish + Finish anyway + Run setup again + Walk through the welcome, permission and test steps once more.