Author SHA1 Message Date
Seb3thehacker e8f51f4947 0.3.01-alpha: wireless around VPNs and early Wi-Fi details, re-signed apps lose the red X 2026-10-15 12:38:06 -07:00
Seb3thehacker 2281347728 links: the repo now lives at twinksoftware/gearslip 2026-10-15 12:38:06 -07:00
Seb3thehacker 6d08fbd871 license: Gearslip now belongs to Twink Software LLC 2026-10-15 12:38:06 -07:00
Seb3thehacker 1322cbaf39 readme: package name and certificate hash for AppVerifier 2026-10-15 08:04:28 -07:00
Seb3thehacker db6f1b8e82 launcher: a patched Waze, signed with someone else's key, loses the red X and can start as the map app 2026-10-15 07:50:34 -07:00
Seb3thehacker 0177975bc8 wireless: wait for the address when the Wi-Fi details come first, and work around or name a VPN that blocks the car 2026-10-15 07:25:59 -07:00
Seb3thehacker 5f30e7b014 readme: an eighth screenshot, coffee nearby in Vela 2026-10-15 07:21:47 -07:00
Seb3thehacker c42406f353 support: Liberapay first again, then Buy Me a Coffee, Monero last 2026-10-14 23:41:34 -07:00
Seb3thehacker e8d025df47 support: say Buy Me a Coffee keeps 5% 2026-10-14 23:39:05 -07:00
Seb3thehacker c6d8d8e787 support: Buy Me a Coffee first in Settings, the popup, the thank-you page and the repo's Sponsor button; Monero last 2026-10-14 23:36:30 -07:00
Seb3thehacker 77b84212db readme: new screenshots for 0.3, with the assistant, a route in the bar, and the song title 2026-10-14 23:34:35 -07:00
Seb3thehacker b12e9651c1 wireless: when Android won't ask for the permissions, a button to enable Nearby devices in the app's settings 2026-10-14 22:06:24 -07:00
Seb3thehacker a5c46967b7 voice: drop the unused archive download and commons-compress with it, which CodeQL flagged 2026-10-14 21:11:40 -07:00
27 changed files with 157 additions and 88 deletions

No files matched your search

+1 -1
View File
@@ -10,6 +10,6 @@ liberapay: Seb3thehacker
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
buy_me_a_coffee: seb3thehacker
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
Thanks for reporting. Check the [open issues](https://github.com/Seb3thehacker/gearslip/issues) first, in case someone has already reported it.
Thanks for reporting. Check the [open issues](https://github.com/twinksoftware/gearslip/issues) first, in case someone has already reported it.
- type: input
id: car
+8 -19
View File
@@ -1,26 +1,15 @@
Gearslip 0.3 goes wireless, takes voice commands, and works with the car's rotary knob.
## New
- **Wireless Android Auto.** Pair the phone with the car once, and leave the cable at home.
- **Voice control, on the phone.** Say "navigate to a gas station", "play" a song or artist, or open an app by name. Gearslip turns speech into text itself, so it needs no Google account. "Navigate to" picks the nearest place and stops at the map app's start screen.
- **The rotary knob.** The car's knob moves between buttons, so cars without a touchscreen work too.
- **Music over Bluetooth or USB.** Bluetooth plays as the phone normally would. USB has no delay but asks to share audio each drive. Gearslip offers to pair with the car the first time.
- **Directions on the car.** The car speaks each turn from your map app, and shows the directions on its own display where it can.
- **Updates in the app.** Gearslip finds new versions and installs them, with an optional beta channel.
- **Button styles** for the car screen, picked in the intro or the car's settings.
Gearslip 0.3.01 connects wirelessly on more phones and dongles.
## Fixed
- More cars play music over USB, including ones that refused Gearslip's audio before.
- Cars that give no screen size get a sharper picture, up to 1080p, and taps land where you press on cars that crop the edges.
- A new destination mid-route replaces the old route, and switching map apps ends the old app's route.
- Tidal's like and shuffle buttons show their real state.
- MapQuest finds your location.
- Wireless works with dongles that send their Wi-Fi details before their address.
- A VPN no longer stops a wireless drive without a word. Gearslip tries the phone's ordinary route around it, and if the VPN still blocks the car, Gearslip says so. To fix it for good, exclude Gearslip in your VPN app.
- When Android won't ask for the Nearby devices permission, a button opens Gearslip's settings, where you can turn it on.
## Changed
- The app is half the size: 30 MB instead of 65.
- On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode.
- The car warns you when the phone's location is off.
- Support Gearslip through Liberapay or Monero, in Settings.
- An app that someone else has re-signed, such as a modified Waze, no longer shows the red X when it accepts Gearslip.
- Support Gearslip through Liberapay, Buy Me a Coffee, or Monero, in Settings.
- Gearslip now belongs to Twink Software LLC, and its code lives at github.com/twinksoftware/gearslip. Old links still work.
- The README lists Gearslip's signing certificate, so you can check your copy with AppVerifier.
## Known issues
- Phone calls are unstable right now and may drop or fail to connect.
+1 -1
View File
@@ -5,7 +5,7 @@ start there if you aren't sure where to begin.
## Reporting a bug
Open an issue on [GitHub](https://github.com/Seb3thehacker/gearslip/issues) and include:
Open an issue on [GitHub](https://github.com/twinksoftware/gearslip/issues) and include:
- your car's make, model, and year, and whether the head unit is the original one; and
- the log from the drive. Open Logs on the phone app's home screen and tap Share log.
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (C) 2026 seb
Copyright (C) 2026 Twink Software LLC and contributors
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
+1 -1
View File
@@ -6,4 +6,4 @@ You may use, run, and modify Gearslip freely. If you distribute a modified versi
## Commercial licensing
If the AGPL's terms don't work for you, the author sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact me on Matrix; the address is on my profile.
If the AGPL's terms don't work for you, Twink Software LLC, which owns Gearslip, sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact us on Matrix; the address is on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
+19 -5
View File
@@ -7,7 +7,7 @@ An Android Auto alternative built for everyone.
[![forthebadge](https://forthebadge.com/badges/compatibility-club-penguin.svg)](https://forthebadge.com)
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/Seb3thehacker/gearslip)
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/gearslip)
Questions, ideas?
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
@@ -29,12 +29,14 @@ screen-sharing mode.
| | |
|---|---|
| ![The launcher, with apps grouped into phone, maps, music, and messaging](docs/screenshots/launcher.png) | ![Vela Maps giving turn-by-turn directions, with the music player beside the map](docs/screenshots/navigation.png) |
| Apps sorted by kind. A check marks the ones that work. | Vela Maps navigating, with the player beside it. |
| ![The launcher, with every car app on the phone and the route in the bar below](docs/screenshots/launcher.png) | ![Vela Maps showing the route, with the music player beside the map](docs/screenshots/navigation.png) |
| Your car apps in one place. A red X marks the ones that don't work yet. | Vela Maps navigating, with the player beside it. |
| ![ViVi Music's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Organic Maps](docs/screenshots/message.png) |
| ViVi Music in Gearslip's player. | A new message, read aloud at a tap, over the map. |
| ![The dashboard, with the weather, the next calendar event, and recent notifications](docs/screenshots/dashboard.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) |
| The weather, your next event, and what you missed. | Browse the web while parked. |
| ![The voice assistant listening over Organic Maps](docs/screenshots/assistant.png) | ![Coffee shops near you in Vela Maps, with the music player beside the map](docs/screenshots/places.png) |
| Ask for a place, a song, or an app. | Find coffee nearby without leaving the map. |
## Features
@@ -95,6 +97,17 @@ screen-sharing mode.
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
Settings.
## Verify your download
Check that your copy of Gearslip is genuine with
[AppVerifier](https://github.com/soupslurpr/AppVerifier): share this block to it, or compare it
with the output of `apksigner verify --print-certs`.
```
app.seb3thehacker.gearslip
4C:C5:6E:BD:D1:3A:54:D2:60:EC:A9:B7:E2:B2:AE:6B:30:E2:F2:25:BF:DA:8F:50:8F:07:09:ED:F0:84:94:8B
```
## Usage notes
Gearslip can send short notes that show which cars it works in. They stay off unless you
@@ -170,5 +183,6 @@ any of these companies. All other names belong to their owners.
## License
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
commercial license for private use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
Gearslip belongs to Twink Software LLC and is licensed under AGPL-3.0; see [LICENSE](LICENSE).
The LLC also sells a separate commercial license for private use outside the AGPL's terms; see
[NOTICE.md](NOTICE.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+2 -3
View File
@@ -38,9 +38,9 @@ android {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 31
targetSdk = 37
versionCode = 9
versionCode = 10
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.00-alpha"
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.01-alpha"
manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
@@ -129,7 +129,6 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.commons.compress)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
-7
View File
@@ -11,10 +11,3 @@
# Speech-to-text: the native library calls back into these classes by name.
-keep class com.k2fsa.sherpa.onnx.** { *; }
# commons-compress refers to optional codecs Gearslip doesn't ship.
-dontwarn org.tukaani.xz.**
-dontwarn com.github.luben.zstd.**
-dontwarn org.brotli.dec.**
-dontwarn org.objectweb.asm.**
-dontwarn org.osgi.**
@@ -239,6 +239,7 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro
/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */
@Composable
fun CarLauncher() {
val context = LocalContext.current
val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState()
@@ -254,7 +255,7 @@ fun CarLauncher() {
val id = entry.componentId
return Tile(
entry.label, entry.icon, entry.builtIn?.glyph,
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
onLongClick = id?.let { { navigator.showAppMenu(it) } },
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
}
@@ -178,7 +178,7 @@ object CarServices {
_hasNavApps.value = navApps.isNotEmpty()
// Never auto-pick one already known to fail outright - a driver who plugs in should
// land on a map, not on the one tile marked with a red X.
val candidates = navApps.filterNot { KnownApps.isBroken(it.component.packageName) }
val candidates = navApps.filterNot { KnownApps.isBroken(ctx, it.component.packageName) }
val last = CarSettings.lastNav.value
// The one used last, or - first run, nothing on record yet - whichever comes first:
// a driver who plugs in for the first time shouldn't have to go pick one by hand
@@ -1,5 +1,9 @@
package app.seb3thehacker.gearslip.host
import android.content.Context
import android.content.pm.PackageManager
import java.security.MessageDigest
/**
* Apps that have been run through Gearslip from start to finish and behave. The launcher marks
* them so a driver can pick something that works without finding out at the wheel.
@@ -65,7 +69,30 @@ object KnownApps {
"com.generalmagic.magicearth", // Magic Earth: never gets past connecting
)
fun isBroken(packageName: String): Boolean = packageName in broken
fun isBroken(context: Context, packageName: String): Boolean =
packageName in broken && !patched(context, packageName)
/**
* The certificate each broken app's own publisher signs it with. A copy signed with another
* key has been patched to accept Gearslip, like Waze with the waze-gearslip Morphe patch, and
* works.
*/
private val officialSigners = mapOf(
"com.waze" to "03637f6c5d8f604e6fdb79a6ffbfa578de4e318f8da22fc6106665247f8807d7",
)
/** Whether the installed copy is signed by someone other than its publisher. */
private fun patched(context: Context, packageName: String): Boolean {
val official = officialSigners[packageName] ?: return false
val signers = runCatching {
context.packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
.signingInfo?.apkContentsSigners
}.getOrNull() ?: return false
val sha256 = MessageDigest.getInstance("SHA-256")
return signers.isNotEmpty() && signers.none { cert ->
sha256.digest(cert.toByteArray()).joinToString("") { "%02x".format(it) } == official
}
}
/**
* Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to
@@ -18,16 +18,14 @@ import java.util.concurrent.Executors
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
/**
* The speech models Gearslip listens with, in two roles: a streaming one that writes the words
* while the driver talks, and an optional final one that reads the whole phrase again once they
* stop, for a more accurate result. None ships in the APK: each is fetched once and kept in the
* app's own storage. sherpa-onnx's author publishes them twice: as .tar.bz2 archives on GitHub and
* as plain files on Hugging Face. The plain files come first where they exist. Unpacking bzip2 on
* the phone pinned a core and held a download to 150 KB/s on Wi-Fi that does 19 MB/s.
* as plain files on Hugging Face. Gearslip takes the plain files: unpacking bzip2 on the phone
* pinned a core and held a download to 150 KB/s on Wi-Fi that does 19 MB/s.
*/
object SpeechModels {
@@ -45,8 +43,6 @@ object SpeechModels {
/** Empty for Canary, which decodes with attention and has no joiner. */
val joiner: String,
val modelType: String = "",
/** Whether Hugging Face has the files unpacked; otherwise the archive is unpacked on the phone, slowly. */
val plainFiles: Boolean = true,
/** Where the unpacked files are, when not under the archive's own name on Hugging Face. */
private val filesAt: String? = null,
) {
@@ -75,7 +71,6 @@ object SpeechModels {
),
;
val url get() = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/$archive.tar.bz2"
fun fileUrl(name: String) = "https://huggingface.co/${filesAt ?: "csukuangfj/$archive/resolve/main"}/$name"
val files get() = listOf(encoder, decoder, joiner, "tokens.txt").filter { it.isNotEmpty() }
}
@@ -215,7 +210,7 @@ object SpeechModels {
if (model == _selected.value || model == _final.value) SpeechEngine.unload()
}
/** Fetches just the files the recognizer reads, plain where it can, else out of the archive. */
/** Fetches just the files the recognizer reads. */
fun download(context: Context, model: Model) {
val app = context.applicationContext
val state = states.getValue(model)
@@ -235,30 +230,17 @@ object SpeechModels {
state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99))
updateProgress(app)
}
if (model.plainFiles) {
for (name in model.files) {
val conn = open(model.fileUrl(name))
val length = conn.contentLengthLong
val file = File(partial, name)
CountingStream(conn.inputStream, ::progress).use { input ->
file.outputStream().use { input.copyTo(it, 256 * 1024) }
}
check(length <= 0 || file.length() == length) { "$name arrived cut short" }
done += file.length()
}
} else {
val conn = open(model.url)
val counting = CountingStream(conn.inputStream, ::progress)
TarArchiveInputStream(BZip2CompressorInputStream(counting.buffered())).use { tar ->
while (true) {
val entry = tar.nextEntry ?: break
val name = entry.name.substringAfterLast('/')
if (entry.isDirectory || name !in model.files) continue
File(partial, name).outputStream().use { tar.copyTo(it) }
}
for (name in model.files) {
val conn = open(model.fileUrl(name))
val length = conn.contentLengthLong
val file = File(partial, name)
CountingStream(conn.inputStream, ::progress).use { input ->
file.outputStream().use { input.copyTo(it, 256 * 1024) }
}
check(length <= 0 || file.length() == length) { "$name arrived cut short" }
done += file.length()
}
check(model.files.all { File(partial, it).isFile }) { "the archive was missing files" }
check(model.files.all { File(partial, it).isFile }) { "some files were missing" }
target.deleteRecursively()
check(partial.renameTo(target)) { "couldn't store the model" }
GearslipLog.i("speech: downloaded ${model.label}")
@@ -35,13 +35,14 @@ import androidx.compose.ui.platform.LocalContext
/** Where Gearslip lives outside the app: support, the chat room, and the code. */
internal object GearslipLinks {
const val BUY_ME_A_COFFEE = "https://buymeacoffee.com/seb3thehacker"
const val LIBERAPAY = "https://liberapay.com/Seb3thehacker"
/** A card payment page for giving once, any amount. Its row stays hidden while this is empty. */
const val PAYMENT_LINK = ""
/** Gearslip's Monero address. Its row stays hidden while this is empty. */
const val MONERO = "44gTVxQJY3cirBJ41J5i1bPUVnSkTMhn9esSp1kg9MHwJ3TUz8tkkigUTKwiok5zA5RZvNxyM4pqkAfPs79pPLfaNG21pCn"
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
const val SOURCE = "https://github.com/Seb3thehacker/gearslip"
const val SOURCE = "https://github.com/twinksoftware/gearslip"
const val CARS = "$SOURCE/blob/main/CARS.md"
fun open(context: Context, url: String) {
@@ -66,7 +67,7 @@ internal object GearslipLinks {
}
}
/** The ways to support Gearslip. Liberapay always; card and Monero once they're set up. */
/** The ways to support Gearslip: Liberapay first, Monero last, card once it's set up. */
@Composable
internal fun DonateRows() {
val context = LocalContext.current
@@ -84,6 +85,14 @@ internal fun DonateRows() {
icon = Icons.Filled.Favorite,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Buy Me a Coffee",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) },
subtitle = "Give once or monthly, by card. Buy Me a Coffee keeps 5%",
icon = Icons.Filled.Favorite,
trailing = arrow,
)
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
SettingsDivider()
SettingsRow(
@@ -110,7 +119,7 @@ internal fun DonateRows() {
/** The Monero address in full, with ways to pay it from this phone or another device. */
@Composable
private fun MoneroDialog(onDismiss: () -> Unit) {
internal fun MoneroDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
@@ -14,7 +14,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -85,7 +87,12 @@ object DonatePrompt {
internal fun DonateDialog(live: Boolean) {
val context = LocalContext.current
val waiting by remember { DonatePrompt.pending(context) }.collectAsStateWithLifecycle()
var monero by remember { mutableStateOf(false) }
if (!waiting || live) return
if (monero) {
MoneroDialog { DonatePrompt.answer(context, donated = true) }
return
}
fun give(url: String) {
DonatePrompt.answer(context, donated = true)
GearslipLinks.open(context, url)
@@ -105,11 +112,17 @@ internal fun DonateDialog(live: Boolean) {
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
Text("Support on Liberapay")
}
OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) {
Text("Buy me a coffee")
}
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
Text("Give once by card")
}
}
OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) {
Text("Monero")
}
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
Text("Not now")
}
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.bluetooth.BluetoothDevice
import android.content.pm.PackageManager
@@ -65,8 +66,18 @@ internal fun WirelessButton() {
Text(
"Gearslip needs Bluetooth and nearby Wi-Fi access for this.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
// Once Android stops asking, the only way back is the app's own permission settings.
TextButton(
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}
},
modifier = Modifier.padding(bottom = 4.dp),
) { Text("Enable Nearby devices permission") }
}
Button(
onClick = {
@@ -77,9 +77,9 @@ object UpdateChecker {
private const val CHANNEL = "updates"
private const val NOTIFICATION_ID = 4100
private const val DAY_MS = 24 * 60 * 60 * 1000L
private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest"
private const val LATEST = "https://api.github.com/repos/twinksoftware/gearslip/releases/latest"
/** Newest first, pre-releases included. Ten covers any run of betas between two releases. */
private const val ALL = "https://api.github.com/repos/Seb3thehacker/gearslip/releases?per_page=10"
private const val ALL = "https://api.github.com/repos/twinksoftware/gearslip/releases?per_page=10"
/** Set when the driver tapped Install but first had to allow installs from Gearslip. */
@Volatile private var installWhenAllowed = false
@@ -171,6 +171,8 @@ object WirelessLink {
val input = DataInputStream(socket.inputStream)
val output = socket.outputStream
var target: Pair<String, Int>? = null
// Wi-Fi details that came before the address, joined once the StartRequest brings it.
var waitingJoin: ((String, Int) -> Unit)? = null
try {
while (!Thread.currentThread().isInterrupted) {
val length = input.readUnsignedShort()
@@ -201,8 +203,14 @@ object WirelessLink {
val port = Wire.varint(f, 2)?.toInt() ?: 0
log.i("<- StartRequest: projection at $ip:$port")
target = ip to port
send(output, MSG_INFO_REQUEST, ByteArray(0))
log.i("-> InfoRequest")
val join = waitingJoin
if (join != null) {
waitingJoin = null
join(ip, port)
} else {
send(output, MSG_INFO_REQUEST, ByteArray(0))
log.i("-> InfoRequest")
}
}
MSG_INFO_RESPONSE -> {
val f = Wire.fields(body)
@@ -212,11 +220,15 @@ object WirelessLink {
val bssid = text(3)
val security = Wire.varint(f, 4) ?: -1
log.i("<- InfoResponse: ssid=\"$ssid\" bssid=$bssid security=$security password=${if (password.isEmpty()) "none" else "(${password.length} chars)"}")
val (ip, port) = target ?: run {
log.w("InfoResponse before StartRequest: no address to connect to yet")
"" to 0
val join = { ip: String, port: Int -> joinWifi(context, output, name, ssid, password, bssid, ip, port) }
val known = target
if (known != null) {
join(known.first, known.second)
} else {
// Some dongles send the Wi-Fi details first; the address follows a moment later.
log.i("InfoResponse before StartRequest: waiting for the address")
waitingJoin = join
}
joinWifi(context, output, name, ssid, password, bssid, ip, port)
}
MSG_PING -> send(output, MSG_PONG, body)
MSG_CONNECTION_STATUS, MSG_START_RESPONSE -> {
@@ -369,7 +381,7 @@ object WirelessLink {
log.i("probe: Bluetooth (any device) calls ${profileState(calls)}, media audio ${profileState(audio)}")
log.i(
"probe: background data ${manager?.restrictBackgroundStatus} (1 off, 2 allowed, 3 restricted), " +
"active network ${manager?.activeNetwork}, car network $net",
"active network ${manager?.activeNetwork}, car network $net, VPN ${if (vpnActive(context)) "on" else "off"}",
)
}.onFailure { log.w("probe: couldn't read the link (${it.message})") }
for (p in listOf(port, 5288, 5277, 80, 443).distinct()) {
@@ -387,6 +399,12 @@ object WirelessLink {
}
}
/** Whether a VPN carries this app's traffic. Android then refuses to bind sockets to the car's network (EPERM). */
private fun vpnActive(context: Context): Boolean = runCatching {
val manager = context.getSystemService(ConnectivityManager::class.java)
manager.getNetworkCapabilities(manager.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}.getOrDefault(false)
private fun profileState(state: Int?) = when (state) {
BluetoothProfile.STATE_CONNECTED -> "connected"
BluetoothProfile.STATE_CONNECTING -> "connecting"
@@ -408,6 +426,9 @@ object WirelessLink {
SessionStatus.connecting("Wi-Fi: reaching $name")
lostNetwork = null
var last: Exception? = null
// Set once Android refuses to bind to the car's network, which a VPN causes. Plain sockets
// then go through the VPN, and VPNs that let local traffic past still reach the car.
var bindRefused = false
repeat(TCP_TRIES) { attempt ->
if (run != tcpRun) {
log.i("TCP: stopping the tries on the old network - the car started over")
@@ -423,7 +444,7 @@ object WirelessLink {
if (main != null && attempt == 0) log.i("TCP: the phone's main Wi-Fi is on the car's network, using it")
val socket = when {
main != null -> main.socketFactory.createSocket()
lostNetwork == net -> Socket()
lostNetwork == net || bindRefused -> Socket()
else -> net.socketFactory.createSocket()
}
socket.tcpNoDelay = true
@@ -441,11 +462,23 @@ object WirelessLink {
} catch (e: Exception) {
last = e
log.w("TCP: try ${attempt + 1} to $ip:$port failed: ${e.message}")
if (!bindRefused && e.message?.contains("EPERM") == true) {
bindRefused = true
log.i("TCP: Android won't let Gearslip use the car's network directly; trying the phone's ordinary route")
}
if (attempt == 0) probe(context, net, ip, port)
Thread.sleep(1_000)
}
}
log.e("TCP: gave up on $ip:$port", last)
if (vpnActive(context)) {
SessionStatus.failed(
"A VPN is blocking the car",
"Gearslip joined $name's network, but a VPN on this phone won't let it reach the car. " +
"Turn the VPN off, or exclude Gearslip in its settings, then try again.",
)
return
}
SessionStatus.failed(
"Car's Wi-Fi joined, but no connection",
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
-2
View File
@@ -14,7 +14,6 @@ material = "1.14.0"
constraintlayout = "2.2.1"
zxing = "3.5.4"
okhttp = "5.4.0"
commonsCompress = "1.28.0"
kotlinxSerializationJson = "1.11.0"
ossLicenses = "17.5.1"
webkit = "1.16.0"
@@ -50,7 +49,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" }
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }