mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
1 parent
e72e99d699
commit
3d3e7669ac
7 files changed
+352
-19
No files matched your search
@@ -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 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<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)) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -96,6 +96,14 @@
|
||||
<string name="recommended_about_ytm">A YouTube Music player.</string>
|
||||
<string name="recommended_about_antennapod">Podcasts, chapters and all.</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 -->
|
||||
<string name="settings_title">Settings</string>
|
||||
|
||||
Reference in new issue
Block a user