diff --git a/docs/BUILDING_APPS.md b/docs/BUILDING_APPS.md index 9cd9f86..4a7baa7 100644 --- a/docs/BUILDING_APPS.md +++ b/docs/BUILDING_APPS.md @@ -469,6 +469,44 @@ home-screen pane comes later. Anything that needs its own rendering belongs in a --- +## Works with Gearslip + +Once your app runs well on Gearslip, say so in two places: your manifest and your README. + +### In your manifest + +Add these lines to ``: + +```xml + + +``` + +Gearslip then marks your app "Works with Gearslip" in its Recommended apps list and on your app's +page in Gearslip. An installed app with the flag gets its own place at the top of the list. + +The second entry is optional. It gives your page one sentence on what your app does in the car, +such as "Spots trackers and cameras nearby." Write it in place with `android:value` if you have no +string resource for it. + +The flag is a label. Gearslip finds and runs your app the same way with or without it. + +### In your README + +Add the badge: + +```markdown +[![Works with Gearslip](https://raw.githubusercontent.com/twinksoftware/gearslip/main/docs/badge/works-with-gearslip.svg)](https://github.com/twinksoftware/gearslip) +``` + +It matches the flat badges most READMEs already show. + +--- + ## Further reading - [README.md](../README.md) - what Gearslip is. diff --git a/docs/badge/works-with-gearslip.svg b/docs/badge/works-with-gearslip.svg new file mode 100644 index 0000000..44af41c --- /dev/null +++ b/docs/badge/works-with-gearslip.svg @@ -0,0 +1,16 @@ + + Works with Gearslip + + + + + + + + + + + works with + Gearslip + + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AppPage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AppPage.kt new file mode 100644 index 0000000..fec61a3 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AppPage.kt @@ -0,0 +1,167 @@ +package app.seb3thehacker.gearslip.ui + +import android.content.Intent +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.graphics.drawable.toBitmap +import app.seb3thehacker.gearslip.R + +/** One app as its page shows it. [url] is where to get it; apps found by their flag have none. */ +class PageApp(val name: String, val packageName: String, val url: String?, val about: String?) + +/** + * One app's page, over whatever opened it: its icon, its own sentence, the Works with Gearslip + * mark when it declares the flag, and one button, to open it or to get it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppPage(app: PageApp, onDismiss: () -> Unit) { + val context = LocalContext.current + val icon = remember(app.packageName) { runCatching { context.packageManager.getApplicationIcon(app.packageName) }.getOrNull() } + val declares = remember(app.packageName) { WorksWith.declares(context.packageManager, app.packageName) } + val about = remember(app.packageName) { app.about ?: WorksWith.about(context.packageManager, app.packageName) } + val installed = icon != null + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(16.dp)) + Box( + Modifier.size(96.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + if (icon != null) { + Image(icon.toBitmap(192, 192).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize()) + } else { + Text(app.name.take(1), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + } + } + Spacer(Modifier.height(16.dp)) + Text(app.name, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) + if (declares) { + Spacer(Modifier.height(8.dp)) + WorksWithMark() + } + if (about != null) { + Spacer(Modifier.height(16.dp)) + Text(about, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + Spacer(Modifier.height(24.dp)) + if (installed) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(18.dp)) + Text( + stringResource(R.string.recommended_installed), + style = MaterialTheme.typography.labelLarge, + color = INSTALLED_GREEN, + modifier = Modifier.padding(start = 6.dp), + ) + } + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.app_page_on_car), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + val launch = remember(app.packageName) { context.packageManager.getLaunchIntentForPackage(app.packageName) } + if (launch != null) { + Spacer(Modifier.height(16.dp)) + Button(onClick = { runCatching { context.startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } }, Modifier.fillMaxWidth()) { + Text(stringResource(R.string.app_page_open, app.name)) + } + } + if (app.url != null) { + TextButton(onClick = { openPage(context, app.url) }) { Text(stringResource(R.string.app_page_github)) } + } + } else if (app.url != null) { + Text( + stringResource(R.string.app_page_get_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = { openPage(context, app.url) }, Modifier.fillMaxWidth()) { + Text(stringResource(R.string.app_page_get, app.name)) + } + } + Spacer(Modifier.height(24.dp)) + } + } + } +} + +/** The Works with Gearslip mark: the logo's gear and the words, on a tinted pill. */ +@Composable +fun WorksWithMark(modifier: Modifier = Modifier) { + Row( + modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(start = 8.dp, end = 10.dp, top = 3.dp, bottom = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Icon(painterResource(R.drawable.works_with_gear), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(14.dp)) + Text(stringResource(R.string.works_with_gearslip), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt index 046ef84..7e62bc3 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt @@ -9,6 +9,7 @@ import android.graphics.drawable.Drawable import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,7 +35,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -105,32 +109,60 @@ fun RecommendedAppsScreen(onBack: () -> Unit) { internal const val SHOW_RECOMMENDED_APPS = false /** The same green as the launcher's "works" check. */ -private val INSTALLED_GREEN = Color(0xFF43A047) +internal val INSTALLED_GREEN = Color(0xFF43A047) -/** The apps by section, each with a GitHub button. Installed ones say so in green. */ +/** + * The apps by section, each with a GitHub button; tapping the rest of a row opens the app's page. + * Installed ones say so in green. Installed apps that declare the Works with Gearslip flag come + * first, under their own heading, unless the list already has them. + */ @Composable internal fun RecommendedAppsList(modifier: Modifier = Modifier) { val context = LocalContext.current + var page by remember { mutableStateOf(null) } + val declared = remember { + val listed = recommended.flatMap { (_, apps) -> apps.map { it.packageName } }.toSet() + WorksWith.installed(context).filter { it.packageName !in listed } + } Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) { - for ((section, apps) in recommended) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - stringResource(section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - for (app in apps) AppRow(app) { openPage(context, app.url) } + if (declared.isNotEmpty()) { + Section(stringResource(R.string.works_with_gearslip)) { + for (app in declared) { + AppRow(app.label, app.packageName, app.about, onPage = { page = PageApp(app.label, app.packageName, null, app.about) }, onGitHub = null) + } } } + for ((section, apps) in recommended) { + Section(stringResource(section)) { + for (app in apps) { + val text = stringResource(app.about) + AppRow(app.name, app.packageName, text, onPage = { page = PageApp(app.name, app.packageName, app.url, text) }, onGitHub = { openPage(context, app.url) }) + } + } + } + } + page?.let { AppPage(it, onDismiss = { page = null }) } +} + +@Composable +private fun Section(title: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + content() } } @Composable -private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) { +private fun AppRow(name: String, packageName: String, about: String?, onPage: () -> Unit, onGitHub: (() -> Unit)?) { val context = LocalContext.current - val icon = remember(app.packageName) { installedIcon(context, app.packageName) } - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + val icon = remember(packageName) { installedIcon(context, packageName) } + val declares = remember(packageName) { WorksWith.declares(context.packageManager, packageName) } + Row(Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).clickable(onClick = onPage), verticalAlignment = Alignment.CenterVertically) { Box( Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh), contentAlignment = Alignment.Center, @@ -138,14 +170,15 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) { if (icon != null) { Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize()) } else { - Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + Text(name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) } } Column(Modifier.weight(1f).padding(horizontal = 14.dp)) { // Colours set outright: the setup guide draws this on its own background, with no // Surface to hand down a content colour, and unset text came out dark on dark. - Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) - Text(stringResource(app.about), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) + if (about != null) Text(about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (declares) WorksWithMark(Modifier.padding(top = 4.dp)) if (icon != null) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) { Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp)) @@ -158,14 +191,14 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) { } } } - OutlinedButton(onClick = onOpen) { Text("GitHub") } + if (onGitHub != null) OutlinedButton(onClick = onGitHub) { Text("GitHub") } } } private fun installedIcon(context: Context, packageName: String): Drawable? = runCatching { context.packageManager.getApplicationIcon(packageName) }.getOrNull() -private fun openPage(context: Context, url: String) { +internal fun openPage(context: Context, url: String) { runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WorksWith.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WorksWith.kt new file mode 100644 index 0000000..30e7c69 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WorksWith.kt @@ -0,0 +1,54 @@ +package app.seb3thehacker.gearslip.ui + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle + +/** + * Apps that say they work with Gearslip, by a line in their manifest's ``: + * + * ```xml + * + * + * ``` + * + * The second line is optional: one sentence for the app's page in Gearslip. Gearslip already sees + * every app with a launcher icon (the manifest's MAIN/LAUNCHER query), so reading these needs + * nothing more. + */ +object WorksWith { + + const val FLAG = "app.seb3thehacker.gearslip.works_with" + const val ABOUT = "app.seb3thehacker.gearslip.about" + + class Declared(val packageName: String, val label: String, val about: String?) + + private fun meta(manager: PackageManager, pkg: String): Bundle? = + runCatching { manager.getApplicationInfo(pkg, PackageManager.GET_META_DATA).metaData }.getOrNull() + + fun declares(manager: PackageManager, pkg: String): Boolean = meta(manager, pkg)?.getBoolean(FLAG) == true + + /** The app's own sentence about itself, from a string resource or written in place. */ + fun about(manager: PackageManager, pkg: String): String? { + val meta = meta(manager, pkg) ?: return null + val res = meta.getInt(ABOUT) + if (res != 0) return runCatching { manager.getResourcesForApplication(pkg).getString(res) }.getOrNull() + return meta.getString(ABOUT)?.takeIf { it.isNotBlank() } + } + + /** Every installed app that declares the flag, by name. */ + fun installed(context: Context): List { + val manager = context.packageManager + val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + return manager.queryIntentActivities(launcher, 0) + .map { it.activityInfo.packageName } + .distinct() + .filter { it != context.packageName && declares(manager, it) } + .map { pkg -> + val label = runCatching { manager.getApplicationInfo(pkg, 0).loadLabel(manager).toString() }.getOrDefault(pkg) + Declared(pkg, label, about(manager, pkg)) + } + .sortedBy { it.label.lowercase() } + } +} diff --git a/gearslip/src/main/res/drawable/works_with_gear.xml b/gearslip/src/main/res/drawable/works_with_gear.xml new file mode 100644 index 0000000..c636bbf --- /dev/null +++ b/gearslip/src/main/res/drawable/works_with_gear.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index 13d2573..782a194 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -96,6 +96,14 @@ A YouTube Music player. Podcasts, chapters and all. Spots trackers and cameras nearby. + + Works with Gearslip + + Gearslip shows it on the car screen. + Install it, and Gearslip shows it on the car screen. + Open %1$s + Get %1$s + View on GitHub Settings