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
5 changes: 5 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ dependencies {
implementation(project(":core"))
implementation(project(":playback"))
if (!foss) implementation(project(":cast"))
// Scanning another device's setup code with Google's code scanner (no camera permission). A
// FOSS build leaves it out: the system camera opens the code's hocket://setup link instead.
if (!foss) implementation(libs.play.services.code.scanner)
// Drawing the setup code (pure Java, so FOSS builds keep it).
implementation(libs.zxing.core)

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
Expand Down
10 changes: 10 additions & 0 deletions android/app/src/foss/java/app/hocket/SetupScanner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package app.hocket

import android.content.Context

/** A FOSS build has no in-app scanner: the system camera opens a setup code's hocket://setup link. */
internal object SetupScanner {
const val available = false

fun scan(context: Context, onResult: (String) -> Unit, onError: () -> Unit) = Unit
}
22 changes: 22 additions & 0 deletions android/app/src/gms/java/app/hocket/SetupScanner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package app.hocket

import android.content.Context
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning

/**
* Scanning another device's setup code with Google's code scanner: Play services draws the
* camera UI, so the app needs no camera permission. [onResult] gets the code's text, [onError] runs
* when the scanner could not start (no camera, its module not downloaded yet); cancelling calls neither.
*/
internal object SetupScanner {
const val available = true

fun scan(context: Context, onResult: (String) -> Unit, onError: () -> Unit) {
val options = GmsBarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
GmsBarcodeScanning.getClient(context, options).startScan()
.addOnSuccessListener { code -> code.rawValue?.let(onResult) }
.addOnFailureListener { onError() }
}
}
8 changes: 8 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@
<category android:name="android.intent.category.APP_MUSIC" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Another device's setup code (Set up another device): hocket://setup?server=…, from
the system camera, a shared link or the in-app scanner (SetupLink). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="hocket" android:host="setup" />
</intent-filter>
</activity>
<!-- Assistant's discovery intent ("play … on Hocket"): no UI, finishes once it has played or
handed the query to search (MediaSearchActivity; plays directly only with "Allow control
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/java/app/hocket/AppShortcuts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,12 @@ object AppLinks {
fun openSearch(query: String?) { _search.value = SearchLink(query?.takeIf { it.isNotBlank() }) }

fun consume(link: SearchLink) { _search.compareAndSet(link, null) }

private val _setup = MutableStateFlow<app.hocket.ui.screens.setup.SetupLink?>(null)
/** Another device's setup link (hocket://setup), until setup applies it or the shell turns it away. */
val setup: StateFlow<app.hocket.ui.screens.setup.SetupLink?> = _setup.asStateFlow()

fun openSetup(link: app.hocket.ui.screens.setup.SetupLink) { _setup.value = link }

fun consume(link: app.hocket.ui.screens.setup.SetupLink) { _setup.compareAndSet(link, null) }
}
8 changes: 8 additions & 0 deletions android/app/src/main/java/app/hocket/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@ class MainActivity : ComponentActivity() {
* handled one is cleared.
*/
private fun handleShortcut(intent: Intent?) {
if (intent?.action == Intent.ACTION_VIEW) {
// Another device's setup code (hocket://setup), once, as for shortcuts: never again from Recents.
if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY == 0) {
app.hocket.ui.screens.setup.SetupLink.parse(intent.dataString)?.let(AppLinks::openSetup)
}
setIntent(Intent(intent).setAction(Intent.ACTION_MAIN).setData(null))
return
}
val shortcut = AppShortcuts.parse(this, intent) ?: return
setIntent(Intent(intent).setAction(Intent.ACTION_MAIN).replaceExtras(null as android.os.Bundle?))
(application as HocketApp).runShortcut(shortcut)
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/java/app/hocket/ui/icons/HocketIcons.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ object HocketIcons {
val Chromecast2: ImageVector by lazy { symbol("Filled.Chromecast2", "M 9 21 Q 6.075 21 4.038 18.963 Q 2.001 16.926 2 14 Q 2 11.35 3.713 9.388 Q 5.426 7.426 8 7.074 L 8 5.7 Q 8 4.15 9.075 3.075 Q 10.15 2 11.7 2 Q 12.55 2 13.313 2.375 Q 14.076 2.75 14.6 3.4 L 19.775 9.925 Q 20.075 9.8 20.4 9.863 Q 20.725 9.926 20.95 10.2 L 22.475 12.125 Q 22.75 12.45 22.687 12.85 Q 22.624 13.25 22.3 13.525 L 21.125 14.45 Q 20.8 14.725 20.4 14.675 Q 20 14.625 19.725 14.3 L 18.225 12.375 Q 18 12.1 18 11.763 Q 18 11.426 18.2 11.15 L 13.025 4.625 Q 12.775 4.325 12.438 4.163 Q 12.101 4.001 11.7 4 Q 10.975 4 10.488 4.488 Q 10.001 4.976 10 5.7 L 10 7.075 Q 12.575 7.425 14.288 9.388 Q 16.001 11.351 16 14 Q 16 16.925 13.963 18.963 Q 11.926 21.001 9 21 M 9 19 Q 11.075 19 12.538 17.537 Q 14.001 16.074 14 14 Q 13.999 11.926 12.537 10.463 Q 11.075 9 9 9 Q 6.925 9 5.463 10.463 Q 4.001 11.926 4 14 Q 3.999 16.074 5.463 17.538 Q 6.927 19.002 9 19") }
val ChromecastDevice: ImageVector by lazy { symbol("Filled.ChromecastDevice", "M 21 14.5 Q 20.575 14.5 20.288 14.212 Q 20.001 13.924 20 13.5 L 20 10.5 Q 20 10.075 20.288 9.788 Q 20.576 9.501 21 9.5 L 22 9.5 Q 22.425 9.5 22.713 9.788 Q 23.001 10.076 23 10.5 L 23 13.5 Q 23 13.925 22.712 14.213 Q 22.424 14.501 22 14.5 Z M 17 16 L 11 16 Q 10.6 16 10.188 16.113 Q 9.776 16.226 9.15 16.5 Q 8.175 16.925 7.475 17.088 Q 6.775 17.251 6 17.25 Q 4.05 17.25 2.525 16.1 Q 1 14.95 1 13.5 L 1 10.5 Q 1 9.025 2.525 7.887 Q 4.05 6.749 6 6.75 Q 6.775 6.75 7.475 6.913 Q 8.175 7.076 9.15 7.5 Q 9.775 7.775 10.188 7.888 Q 10.601 8.001 11 8 L 17 8 Q 17.825 8 18.413 8.588 Q 19.001 9.176 19 10 L 19 14 Q 19 14.825 18.413 15.413 Q 17.826 16.001 17 16 M 2.5 12.5 L 3.5 12.5 Q 3.7 12.5 3.85 12.35 Q 4 12.2 4 12 Q 4 11.8 3.85 11.65 Q 3.7 11.5 3.5 11.5 L 2.5 11.5 Q 2.3 11.5 2.15 11.65 Q 2 11.8 2 12 Q 2 12.2 2.15 12.35 Q 2.3 12.5 2.5 12.5") }
val NestWifiPoint: ImageVector by lazy { symbol("Filled.NestWifiPoint", "M 9 21 Q 6.5 21 4.75 19.25 Q 3 17.5 3 15 L 3 9 Q 3 6.5 4.75 4.75 Q 6.5 3 9 3 L 15 3 Q 17.5 3 19.25 4.75 Q 21 6.5 21 9 L 21 15 Q 21 17.5 19.25 19.25 Q 17.5 21 15 21 Z M 9 19 Q 9 18.575 9.288 18.288 Q 9.576 18.001 10 18 Q 10.424 17.999 10.713 18.288 Q 11.002 18.577 11 19 L 13 19 Q 13 18.575 13.288 18.288 Q 13.576 18.001 14 18 Q 14.424 17.999 14.713 18.288 Q 15.002 18.577 15 19 Q 16.125 19 17.025 18.45 Q 17.925 17.9 18.45 17 L 17 17 Q 17 17.425 16.712 17.713 Q 16.424 18.001 16 18 Q 15.576 17.999 15.288 17.712 Q 15 17.425 15 17 L 13 17 Q 13 17.425 12.712 17.713 Q 12.424 18.001 12 18 Q 11.576 17.999 11.288 17.712 Q 11 17.425 11 17 L 9 17 Q 9 17.425 8.712 17.713 Q 8.424 18.001 8 18 Q 7.576 17.999 7.288 17.712 Q 7 17.425 7 17 L 5.55 17 Q 6.075 17.9 6.975 18.45 Q 7.875 19 9 19") }
val QrCode2: ImageVector by lazy { symbol("Filled.QrCode2", "M 13 21 L 13 19 L 15 19 L 15 21 Z M 11 19 L 11 14 L 13 14 L 13 19 Z M 19 16 L 19 12 L 21 12 L 21 16 Z M 17 12 L 17 10 L 19 10 L 19 12 Z M 5 14 L 5 12 L 7 12 L 7 14 Z M 3 12 L 3 10 L 5 10 L 5 12 Z M 12 5 L 12 3 L 14 3 L 14 5 Z M 4.5 7.5 L 7.5 7.5 L 7.5 4.5 L 4.5 4.5 Z M 3 8 L 3 4 Q 3 3.575 3.288 3.288 Q 3.576 3.001 4 3 L 8 3 Q 8.425 3 8.713 3.288 Q 9.001 3.576 9 4 L 9 8 Q 9 8.425 8.712 8.713 Q 8.424 9.001 8 9 L 4 9 Q 3.575 9 3.288 8.712 Q 3.001 8.424 3 8 M 4.5 19.5 L 7.5 19.5 L 7.5 16.5 L 4.5 16.5 Z M 3 20 L 3 16 Q 3 15.575 3.288 15.288 Q 3.576 15.001 4 15 L 8 15 Q 8.425 15 8.713 15.288 Q 9.001 15.576 9 16 L 9 20 Q 9 20.425 8.712 20.713 Q 8.424 21.001 8 21 L 4 21 Q 3.575 21 3.288 20.712 Q 3.001 20.424 3 20 M 16.5 7.5 L 19.5 7.5 L 19.5 4.5 L 16.5 4.5 Z M 15 8 L 15 4 Q 15 3.575 15.288 3.288 Q 15.576 3.001 16 3 L 20 3 Q 20.425 3 20.713 3.288 Q 21.001 3.576 21 4 L 21 8 Q 21 8.425 20.712 8.713 Q 20.424 9.001 20 9 L 16 9 Q 15.575 9 15.288 8.712 Q 15.001 8.424 15 8 M 17 21 L 17 18 L 15 18 L 15 16 L 19 16 L 19 19 L 21 19 L 21 21 Z M 13 14 L 13 12 L 17 12 L 17 14 Z M 9 14 L 9 12 L 7 12 L 7 10 L 13 10 L 13 12 L 11 12 L 11 14 Z M 10 9 L 10 5 L 12 5 L 12 7 L 14 7 L 14 9 Z M 5.25 6.75 L 5.25 5.25 L 6.75 5.25 L 6.75 6.75 Z M 5.25 18.75 L 5.25 17.25 L 6.75 17.25 L 6.75 18.75 Z M 17.25 6.75 L 17.25 5.25 L 18.75 5.25 L 18.75 6.75 Z") }
val QrCodeScanner: ImageVector by lazy { symbol("Filled.QrCodeScanner", "M 3 7 Q 2.575 7 2.288 6.712 Q 2.001 6.424 2 6 L 2 3 Q 2 2.575 2.288 2.288 Q 2.576 2.001 3 2 L 6 2 Q 6.425 2 6.713 2.288 Q 7.001 2.576 7 3 Q 6.999 3.424 6.712 3.713 Q 6.425 4.002 6 4 L 4 4 L 4 6 Q 4 6.425 3.712 6.713 Q 3.424 7.001 3 7 M 3 22 Q 2.575 22 2.288 21.712 Q 2.001 21.424 2 21 L 2 18 Q 2 17.575 2.288 17.288 Q 2.576 17.001 3 17 Q 3.424 16.999 3.713 17.288 Q 4.002 17.577 4 18 L 4 20 L 6 20 Q 6.425 20 6.713 20.288 Q 7.001 20.576 7 21 Q 6.999 21.424 6.712 21.713 Q 6.425 22.002 6 22 Z M 18 22 Q 17.575 22 17.288 21.712 Q 17.001 21.424 17 21 Q 16.999 20.576 17.288 20.288 Q 17.577 20 18 20 L 20 20 L 20 18 Q 20 17.575 20.288 17.288 Q 20.576 17.001 21 17 Q 21.424 16.999 21.713 17.288 Q 22.002 17.577 22 18 L 22 21 Q 22 21.425 21.712 21.713 Q 21.424 22.001 21 22 Z M 20.288 6.712 Q 20 6.425 20 6 L 20 4 L 18 4 Q 17.575 4 17.288 3.712 Q 17.001 3.424 17 3 Q 16.999 2.576 17.288 2.288 Q 17.577 2 18 2 L 21 2 Q 21.425 2 21.713 2.288 Q 22.001 2.576 22 3 L 22 6 Q 22 6.425 21.712 6.713 Q 21.424 7.001 21 7 Q 20.576 6.999 20.288 6.712 M 17.5 19 L 17.5 17.5 L 19 17.5 L 19 19 Z M 17.5 16 L 17.5 14.5 L 19 14.5 L 19 16 Z M 16 17.5 L 16 16 L 17.5 16 L 17.5 17.5 Z M 14.5 19 L 14.5 17.5 L 16 17.5 L 16 19 Z M 13 17.5 L 13 16 L 14.5 16 L 14.5 17.5 Z M 16 14.5 L 16 13 L 17.5 13 L 17.5 14.5 Z M 14.5 16 L 14.5 14.5 L 16 14.5 L 16 16 Z M 13 14.5 L 13 13 L 14.5 13 L 14.5 14.5 Z M 14 11 Q 13.575 11 13.288 10.712 Q 13.001 10.424 13 10 L 13 6 Q 13 5.575 13.288 5.288 Q 13.576 5.001 14 5 L 18 5 Q 18.425 5 18.713 5.288 Q 19.001 5.576 19 6 L 19 10 Q 19 10.425 18.712 10.713 Q 18.424 11.001 18 11 Z M 6 19 Q 5.575 19 5.288 18.712 Q 5.001 18.424 5 18 L 5 14 Q 5 13.575 5.288 13.288 Q 5.576 13.001 6 13 L 10 13 Q 10.425 13 10.713 13.288 Q 11.001 13.576 11 14 L 11 18 Q 11 18.425 10.712 18.713 Q 10.424 19.001 10 19 Z M 6 11 Q 5.575 11 5.288 10.712 Q 5.001 10.424 5 10 L 5 6 Q 5 5.575 5.288 5.288 Q 5.576 5.001 6 5 L 10 5 Q 10.425 5 10.713 5.288 Q 11.001 5.576 11 6 L 11 10 Q 11 10.425 10.712 10.713 Q 10.424 11.001 10 11 Z M 6.5 17.5 L 9.5 17.5 L 9.5 14.5 L 6.5 14.5 Z M 6.5 9.5 L 9.5 9.5 L 9.5 6.5 L 6.5 6.5 Z M 14.5 9.5 L 17.5 9.5 L 17.5 6.5 L 14.5 6.5 Z") }
}
object Outlined {
val Album: ImageVector by lazy { symbol("Outlined.Album", "M 12 16.5 Q 13.875 16.5 15.188 15.188 Q 16.501 13.876 16.5 12 Q 16.499 10.124 15.188 8.813 Q 13.877 7.502 12 7.5 Q 10.123 7.498 8.813 8.813 Q 7.503 10.128 7.5 12 Q 7.497 13.872 8.813 15.188 Q 10.129 16.504 12 16.5 M 11.288 12.713 Q 11 12.425 11 12 Q 11 11.575 11.288 11.288 Q 11.576 11.001 12 11 Q 12.424 10.999 12.713 11.288 Q 13.002 11.577 13 12 Q 12.998 12.423 12.712 12.713 Q 12.426 13.003 12 13 Q 11.574 12.997 11.288 12.712 M 12 22 Q 9.925 22 8.1 21.212 Q 6.275 20.424 4.925 19.075 Q 3.575 17.726 2.788 15.9 Q 2.001 14.074 2 12 Q 1.999 9.926 2.788 8.1 Q 3.577 6.274 4.925 4.925 Q 6.273 3.576 8.1 2.788 Q 9.927 2 12 2 Q 14.073 2 15.9 2.788 Q 17.727 3.576 19.075 4.925 Q 20.423 6.274 21.213 8.1 Q 22.003 9.926 22 12 Q 21.997 14.074 21.212 15.9 Q 20.427 17.726 19.075 19.075 Q 17.723 20.424 15.9 21.213 Q 14.077 22.002 12 22 M 12 20 Q 15.35 20 17.675 17.675 Q 20 15.35 20 12 Q 20 8.65 17.675 6.325 Q 15.35 4 12 4 Q 8.65 4 6.325 6.325 Q 4 8.65 4 12 Q 4 15.35 6.325 17.675 Q 8.65 20 12 20 M 12 12") }
Expand Down Expand Up @@ -318,6 +320,8 @@ object HocketIcons {
Filled.Chromecast2,
Filled.ChromecastDevice,
Filled.NestWifiPoint,
Filled.QrCode2,
Filled.QrCodeScanner,
Outlined.Album,
Outlined.BarChart,
Outlined.Category,
Expand Down
9 changes: 6 additions & 3 deletions android/app/src/main/java/app/hocket/ui/nav/AccountSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ fun OfflineIndicator(modifier: Modifier = Modifier) {
}

/**
* Who is signed in where, then Settings, Listening stats and Devices (the Connect picker), the
* places not in the bottom bar (so every place stays reachable) and the bar editor.
* Who is signed in where, then Settings, Listening stats, Devices (the Connect picker) and Set up
* another device ([SetupDeviceSheet]), the places not in the bottom bar (so every place stays reachable) and the bar editor.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -104,12 +104,13 @@ fun AccountSheet(
onSettings: () -> Unit,
onStats: () -> Unit,
onDevices: () -> Unit,
onSetUpDevice: () -> Unit,
onPlace: (NavItem) -> Unit,
onEditBar: () -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), modifier = Modifier.testTag("account.sheet")) {
AccountSheetContent(barItems, onSettings, onStats, onDevices, onPlace, onEditBar)
AccountSheetContent(barItems, onSettings, onStats, onDevices, onSetUpDevice, onPlace, onEditBar)
}
}

Expand All @@ -120,6 +121,7 @@ fun AccountSheetContent(
onSettings: () -> Unit,
onStats: () -> Unit,
onDevices: () -> Unit,
onSetUpDevice: () -> Unit,
onPlace: (NavItem) -> Unit,
onEditBar: () -> Unit,
) {
Expand All @@ -142,6 +144,7 @@ fun AccountSheetContent(
SheetRow(HocketIcons.Filled.Settings, stringResource(R.string.account_settings), "account.settings", onSettings)
SheetRow(HocketIcons.Filled.BarChart, stringResource(R.string.account_stats), "account.stats", onStats)
SheetRow(HocketIcons.Filled.Devices, stringResource(R.string.account_devices), "account.devices", onDevices)
SheetRow(HocketIcons.Filled.QrCode2, stringResource(R.string.account_set_up_device), "account.setUpDevice", onSetUpDevice)
val others = NavItem.entries.filter { it !in barItems && it != NavItem.Stats }
if (others.isNotEmpty()) {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,11 @@ private fun MainShell(snackbar: SnackbarHostState) {
val client = LocalCoreClient.current
val nowPlaying = client.nowPlaying.collectAsStateWithLifecycle()
val playerVisible by remember { derivedStateOf { nowPlaying.value != null } }
// Another device's setup code opened while this one is signed in: setup is where it applies.
val alreadySetUp = stringResource(R.string.setup_device_already_set_up)
LaunchedEffect(Unit) {
app.hocket.AppLinks.setup.collect { link -> if (link != null) { app.hocket.AppLinks.consume(link); snackbar.showSnackbar(alreadySetUp) } }
}
BoxWithConstraints(Modifier.fillMaxSize()) {
// The phone's bar: the compact floating pill, or the standard full-width bar.
val floatingBar = setting(SettingKeys.DISPLAY_FLOATING_NAV_BAR).bool ?: true
Expand Down Expand Up @@ -470,6 +475,7 @@ private fun MainShell(snackbar: SnackbarHostState) {
var accountOpen by rememberSaveable { mutableStateOf(false) }
var editorOpen by rememberSaveable { mutableStateOf(false) }
var devicesOpen by rememberSaveable { mutableStateOf(false) }
var setUpDeviceOpen by rememberSaveable { mutableStateOf(false) }
// The phone navigation bar's height (including the edge-to-edge navigation-bar inset), so
// the content column ends above it and the sheet's collapsed anchor sits on it. Known from
// the first frame (fixed height + inset); the measurement only corrects it when it differs.
Expand Down Expand Up @@ -823,13 +829,15 @@ private fun MainShell(snackbar: SnackbarHostState) {
onSettings = { accountOpen = false; nav.navigate(Route.Settings) { launchSingleTop = true } },
onStats = { accountOpen = false; go(NavItem.Stats) },
onDevices = { accountOpen = false; devicesOpen = true },
onSetUpDevice = { accountOpen = false; setUpDeviceOpen = true },
onPlace = { accountOpen = false; go(it) },
onEditBar = { accountOpen = false; editorOpen = true },
onDismiss = { accountOpen = false },
)
}
if (editorOpen) BottomBarEditorSheet(onDismiss = { editorOpen = false })
if (devicesOpen) app.hocket.ui.player.HandoffSheet(onDismiss = { devicesOpen = false })
if (setUpDeviceOpen) SetupDeviceSheet(onDismiss = { setUpDeviceOpen = false })
}
}
}
Expand Down
Loading
Loading