Apps can say they work with Gearslip, and each recommended app gets its own page

An app adds one meta-data line to its manifest and Gearslip marks it Works with Gearslip in the
Recommended apps list. Tapping an app opens its page, with an Open or Get button. A README badge
for app developers is in docs/badge, documented in BUILDING_APPS.md.
This commit is contained in:
Seb3thehacker committed 2026-11-08 01:13:18 -08:00
1 parent e72e99d699
commit 3d3e7669ac
7 files changed
+352 -19

No files matched your search

+38
View File
@@ -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 `<application>`:
```xml
<meta-data
android:name="app.seb3thehacker.gearslip.works_with"
android:value="true" />
<meta-data
android:name="app.seb3thehacker.gearslip.about"
android:resource="@string/gearslip_about" />
```
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 ## Further reading
- [README.md](../README.md) - what Gearslip is. - [README.md](../README.md) - what Gearslip is.
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147" height="20" role="img" aria-label="Works with Gearslip">
<title>Works with Gearslip</title>
<clipPath id="r"><rect width="147" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="88" height="20" fill="#555"/>
<rect x="88" width="59" height="20" fill="#8C1F1F"/>
</g>
<g transform="translate(5 3.5) scale(0.228) translate(-29.5 -25.5)" fill="#fff">
<path fill-rule="evenodd" d="M54 36.18 58.64 30.7 67.2 34.24 66.6 41.4 71.82 54 77.3 58.64 73.76 67.2 66.6 66.6 67.2 73.76 58.64 77.3 54 71.82 49.36 77.3 40.8 73.76 41.4 66.6 34.24 67.2 30.7 58.64 36.18 54 30.7 49.36 34.24 40.8 41.4 41.4 40.8 34.24 49.36 30.7ZM54 46.08A7.92 7.92 0 1 1 53.99 46.08Z"/>
<path d="M78.61 40.7 85.31 43.29 84.74 52.55 77.78 54.31Z"/>
</g>
<g fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-rendering="geometricPrecision">
<text x="22" y="14" textLength="60">works with</text>
<text x="94" y="14" textLength="47">Gearslip</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -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)
}
}
@@ -9,6 +9,7 @@ import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,7 +35,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -105,32 +109,60 @@ fun RecommendedAppsScreen(onBack: () -> Unit) {
internal const val SHOW_RECOMMENDED_APPS = false internal const val SHOW_RECOMMENDED_APPS = false
/** The same green as the launcher's "works" check. */ /** 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 @Composable
internal fun RecommendedAppsList(modifier: Modifier = Modifier) { internal fun RecommendedAppsList(modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
var page by remember { mutableStateOf<PageApp?>(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)) { Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
for ((section, apps) in recommended) { if (declared.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Section(stringResource(R.string.works_with_gearslip)) {
Text( for (app in declared) {
stringResource(section), AppRow(app.label, app.packageName, app.about, onPage = { page = PageApp(app.label, app.packageName, null, app.about) }, onGitHub = null)
style = MaterialTheme.typography.titleMedium, }
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
for (app in apps) AppRow(app) { openPage(context, app.url) }
} }
} }
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 @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 context = LocalContext.current
val icon = remember(app.packageName) { installedIcon(context, app.packageName) } val icon = remember(packageName) { installedIcon(context, packageName) }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { val declares = remember(packageName) { WorksWith.declares(context.packageManager, packageName) }
Row(Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).clickable(onClick = onPage), verticalAlignment = Alignment.CenterVertically) {
Box( Box(
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh), Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -138,14 +170,15 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
if (icon != null) { if (icon != null) {
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize()) Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
} else { } 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)) { Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
// Colours set outright: the setup guide draws this on its own background, with no // 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. // 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(name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
Text(stringResource(app.about), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) if (about != null) Text(about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (declares) WorksWithMark(Modifier.padding(top = 4.dp))
if (icon != null) { if (icon != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.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? = private fun installedIcon(context: Context, packageName: String): Drawable? =
runCatching { context.packageManager.getApplicationIcon(packageName) }.getOrNull() runCatching { context.packageManager.getApplicationIcon(packageName) }.getOrNull()
private fun openPage(context: Context, url: String) { internal fun openPage(context: Context, url: String) {
runCatching { runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} }
@@ -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 `<application>`:
*
* ```xml
* <meta-data android:name="app.seb3thehacker.gearslip.works_with" android:value="true" />
* <meta-data android:name="app.seb3thehacker.gearslip.about" android:resource="@string/gearslip_about" />
* ```
*
* 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<Declared> {
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() }
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launcher icon's gear, cropped to its edges, for the "Works with Gearslip" mark. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="57"
android:viewportHeight="57">
<group android:translateX="-29.5" android:translateY="-25.5">
<path
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
android:fillType="evenOdd"
android:fillColor="#FF000000"/>
<path
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
android:fillColor="#FF000000"/>
</group>
</vector>
+8
View File
@@ -96,6 +96,14 @@
<string name="recommended_about_ytm">A YouTube Music player.</string> <string name="recommended_about_ytm">A YouTube Music player.</string>
<string name="recommended_about_antennapod">Podcasts, chapters and all.</string> <string name="recommended_about_antennapod">Podcasts, chapters and all.</string>
<string name="recommended_about_rfsentinel">Spots trackers and cameras nearby.</string> <string name="recommended_about_rfsentinel">Spots trackers and cameras nearby.</string>
<!-- The mark on apps that declare they work with Gearslip, and the list's heading for them. -->
<string name="works_with_gearslip">Works with Gearslip</string>
<!-- One app's page, opened from Recommended apps. %1$s is the app's name. -->
<string name="app_page_on_car">Gearslip shows it on the car screen.</string>
<string name="app_page_get_hint">Install it, and Gearslip shows it on the car screen.</string>
<string name="app_page_open">Open %1$s</string>
<string name="app_page_get">Get %1$s</string>
<string name="app_page_github">View on GitHub</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>