Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8f51f4947 | ||
|
|
2281347728 | ||
|
|
6d08fbd871 | ||
|
|
1322cbaf39 | ||
|
|
db6f1b8e82 | ||
|
|
0177975bc8 | ||
|
|
5f30e7b014 | ||
|
|
c42406f353 | ||
|
|
e8d025df47 | ||
|
|
c6d8d8e787 | ||
|
|
77b84212db | ||
|
|
b12e9651c1 | ||
|
|
a5c46967b7 |
No files matched your search
@@ -10,6 +10,6 @@ liberapay: Seb3thehacker
|
|||||||
issuehunt: # Replace with a single IssueHunt username
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
polar: # Replace with a single Polar username
|
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
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -5,7 +5,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
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
|
- type: input
|
||||||
id: car
|
id: car
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
Gearslip 0.3 goes wireless, takes voice commands, and works with the car's rotary knob.
|
Gearslip 0.3.01 connects wirelessly on more phones and dongles.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
- More cars play music over USB, including ones that refused Gearslip's audio before.
|
- Wireless works with dongles that send their Wi-Fi details before their address.
|
||||||
- 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 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.
|
||||||
- A new destination mid-route replaces the old route, and switching map apps ends the old app's route.
|
- When Android won't ask for the Nearby devices permission, a button opens Gearslip's settings, where you can turn it on.
|
||||||
- Tidal's like and shuffle buttons show their real state.
|
|
||||||
- MapQuest finds your location.
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
- The app is half the size: 30 MB instead of 65.
|
- An app that someone else has re-signed, such as a modified Waze, no longer shows the red X when it accepts Gearslip.
|
||||||
- On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode.
|
- Support Gearslip through Liberapay, Buy Me a Coffee, or Monero, in Settings.
|
||||||
- The car warns you when the phone's location is off.
|
- Gearslip now belongs to Twink Software LLC, and its code lives at github.com/twinksoftware/gearslip. Old links still work.
|
||||||
- Support Gearslip through Liberapay or Monero, in Settings.
|
- The README lists Gearslip's signing certificate, so you can check your copy with AppVerifier.
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
- Phone calls are unstable right now and may drop or fail to connect.
|
- Phone calls are unstable right now and may drop or fail to connect.
|
||||||
@@ -5,7 +5,7 @@ start there if you aren't sure where to begin.
|
|||||||
|
|
||||||
## Reporting a bug
|
## 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
|
- 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.
|
- the log from the drive. Open Logs on the phone app's home screen and tap Share log.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright (C) 2026 seb
|
Copyright (C) 2026 Twink Software LLC and contributors
|
||||||
|
|
||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
Version 3, 19 November 2007
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ You may use, run, and modify Gearslip freely. If you distribute a modified versi
|
|||||||
|
|
||||||
## Commercial licensing
|
## 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).
|
||||||
@@ -7,7 +7,7 @@ An Android Auto alternative built for everyone.
|
|||||||
|
|
||||||
[](https://forthebadge.com)
|
[](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?
|
Questions, ideas?
|
||||||
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
||||||
@@ -29,12 +29,14 @@ screen-sharing mode.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
| Apps sorted by kind. A check marks the ones that work. | Vela Maps navigating, with the player beside it. |
|
| 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 in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
| ViVi Music in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||||
|  |  |
|
|  |  |
|
||||||
| The weather, your next event, and what you missed. | Browse the web while parked. |
|
| The weather, your next event, and what you missed. | Browse the web while parked. |
|
||||||
|
|  |  |
|
||||||
|
| Ask for a place, a song, or an app. | Find coffee nearby without leaving the map. |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -95,6 +97,17 @@ screen-sharing mode.
|
|||||||
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
|
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
|
||||||
Settings.
|
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
|
## Usage notes
|
||||||
|
|
||||||
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
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
|
## License
|
||||||
|
|
||||||
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
|
Gearslip belongs to Twink Software LLC and is licensed under AGPL-3.0; see [LICENSE](LICENSE).
|
||||||
commercial license for private use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
|
The LLC also sells a separate commercial license for private use outside the AGPL's terms; see
|
||||||
|
[NOTICE.md](NOTICE.md).
|
||||||
|
After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 538 KiB |
|
After Width: | Height: | Size: 569 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 87 KiB |
@@ -38,9 +38,9 @@ android {
|
|||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 9
|
versionCode = 10
|
||||||
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
// -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"
|
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
// 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.
|
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||||
@@ -129,7 +129,6 @@ dependencies {
|
|||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.commons.compress)
|
|
||||||
implementation(libs.androidx.compose.material.icons.core)
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
|||||||
@@ -11,10 +11,3 @@
|
|||||||
|
|
||||||
# Speech-to-text: the native library calls back into these classes by name.
|
# Speech-to-text: the native library calls back into these classes by name.
|
||||||
-keep class com.k2fsa.sherpa.onnx.** { *; }
|
-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. */
|
/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */
|
||||||
@Composable
|
@Composable
|
||||||
fun CarLauncher() {
|
fun CarLauncher() {
|
||||||
|
val context = LocalContext.current
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
val frame by CarEnvironment.frame.collectAsState()
|
val frame by CarEnvironment.frame.collectAsState()
|
||||||
|
|
||||||
@@ -254,7 +255,7 @@ fun CarLauncher() {
|
|||||||
val id = entry.componentId
|
val id = entry.componentId
|
||||||
return Tile(
|
return Tile(
|
||||||
entry.label, entry.icon, entry.builtIn?.glyph,
|
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) } },
|
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ object CarServices {
|
|||||||
_hasNavApps.value = navApps.isNotEmpty()
|
_hasNavApps.value = navApps.isNotEmpty()
|
||||||
// Never auto-pick one already known to fail outright - a driver who plugs in should
|
// 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.
|
// 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
|
val last = CarSettings.lastNav.value
|
||||||
// The one used last, or - first run, nothing on record yet - whichever comes first:
|
// 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
|
// 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
|
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
|
* 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.
|
* 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
|
"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
|
* 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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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
|
* 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
|
* 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
|
* 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
|
* 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
|
* as plain files on Hugging Face. Gearslip takes the plain files: unpacking bzip2 on the phone
|
||||||
* the phone pinned a core and held a download to 150 KB/s on Wi-Fi that does 19 MB/s.
|
* pinned a core and held a download to 150 KB/s on Wi-Fi that does 19 MB/s.
|
||||||
*/
|
*/
|
||||||
object SpeechModels {
|
object SpeechModels {
|
||||||
|
|
||||||
@@ -45,8 +43,6 @@ object SpeechModels {
|
|||||||
/** Empty for Canary, which decodes with attention and has no joiner. */
|
/** Empty for Canary, which decodes with attention and has no joiner. */
|
||||||
val joiner: String,
|
val joiner: String,
|
||||||
val modelType: 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. */
|
/** Where the unpacked files are, when not under the archive's own name on Hugging Face. */
|
||||||
private val filesAt: String? = null,
|
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"
|
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() }
|
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()
|
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) {
|
fun download(context: Context, model: Model) {
|
||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
val state = states.getValue(model)
|
val state = states.getValue(model)
|
||||||
@@ -235,30 +230,17 @@ object SpeechModels {
|
|||||||
state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99))
|
state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99))
|
||||||
updateProgress(app)
|
updateProgress(app)
|
||||||
}
|
}
|
||||||
if (model.plainFiles) {
|
for (name in model.files) {
|
||||||
for (name in model.files) {
|
val conn = open(model.fileUrl(name))
|
||||||
val conn = open(model.fileUrl(name))
|
val length = conn.contentLengthLong
|
||||||
val length = conn.contentLengthLong
|
val file = File(partial, name)
|
||||||
val file = File(partial, name)
|
CountingStream(conn.inputStream, ::progress).use { input ->
|
||||||
CountingStream(conn.inputStream, ::progress).use { input ->
|
file.outputStream().use { input.copyTo(it, 256 * 1024) }
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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()
|
target.deleteRecursively()
|
||||||
check(partial.renameTo(target)) { "couldn't store the model" }
|
check(partial.renameTo(target)) { "couldn't store the model" }
|
||||||
GearslipLog.i("speech: downloaded ${model.label}")
|
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. */
|
/** Where Gearslip lives outside the app: support, the chat room, and the code. */
|
||||||
internal object GearslipLinks {
|
internal object GearslipLinks {
|
||||||
|
const val BUY_ME_A_COFFEE = "https://buymeacoffee.com/seb3thehacker"
|
||||||
const val LIBERAPAY = "https://liberapay.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. */
|
/** A card payment page for giving once, any amount. Its row stays hidden while this is empty. */
|
||||||
const val PAYMENT_LINK = ""
|
const val PAYMENT_LINK = ""
|
||||||
/** Gearslip's Monero address. Its row stays hidden while this is empty. */
|
/** Gearslip's Monero address. Its row stays hidden while this is empty. */
|
||||||
const val MONERO = "44gTVxQJY3cirBJ41J5i1bPUVnSkTMhn9esSp1kg9MHwJ3TUz8tkkigUTKwiok5zA5RZvNxyM4pqkAfPs79pPLfaNG21pCn"
|
const val MONERO = "44gTVxQJY3cirBJ41J5i1bPUVnSkTMhn9esSp1kg9MHwJ3TUz8tkkigUTKwiok5zA5RZvNxyM4pqkAfPs79pPLfaNG21pCn"
|
||||||
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
|
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"
|
const val CARS = "$SOURCE/blob/main/CARS.md"
|
||||||
|
|
||||||
fun open(context: Context, url: String) {
|
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
|
@Composable
|
||||||
internal fun DonateRows() {
|
internal fun DonateRows() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -84,6 +85,14 @@ internal fun DonateRows() {
|
|||||||
icon = Icons.Filled.Favorite,
|
icon = Icons.Filled.Favorite,
|
||||||
trailing = arrow,
|
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()) {
|
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
@@ -110,7 +119,7 @@ internal fun DonateRows() {
|
|||||||
|
|
||||||
/** The Monero address in full, with ways to pay it from this phone or another device. */
|
/** The Monero address in full, with ways to pay it from this phone or another device. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MoneroDialog(onDismiss: () -> Unit) {
|
internal fun MoneroDialog(onDismiss: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -85,7 +87,12 @@ object DonatePrompt {
|
|||||||
internal fun DonateDialog(live: Boolean) {
|
internal fun DonateDialog(live: Boolean) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val waiting by remember { DonatePrompt.pending(context) }.collectAsStateWithLifecycle()
|
val waiting by remember { DonatePrompt.pending(context) }.collectAsStateWithLifecycle()
|
||||||
|
var monero by remember { mutableStateOf(false) }
|
||||||
if (!waiting || live) return
|
if (!waiting || live) return
|
||||||
|
if (monero) {
|
||||||
|
MoneroDialog { DonatePrompt.answer(context, donated = true) }
|
||||||
|
return
|
||||||
|
}
|
||||||
fun give(url: String) {
|
fun give(url: String) {
|
||||||
DonatePrompt.answer(context, donated = true)
|
DonatePrompt.answer(context, donated = true)
|
||||||
GearslipLinks.open(context, url)
|
GearslipLinks.open(context, url)
|
||||||
@@ -105,11 +112,17 @@ internal fun DonateDialog(live: Boolean) {
|
|||||||
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
|
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
|
||||||
Text("Support on Liberapay")
|
Text("Support on Liberapay")
|
||||||
}
|
}
|
||||||
|
OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) {
|
||||||
|
Text("Buy me a coffee")
|
||||||
|
}
|
||||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||||
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
|
||||||
Text("Give once by card")
|
Text("Give once by card")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) {
|
||||||
|
Text("Monero")
|
||||||
|
}
|
||||||
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
|
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
|
||||||
Text("Not now")
|
Text("Not now")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -65,8 +66,18 @@ internal fun WirelessButton() {
|
|||||||
Text(
|
Text(
|
||||||
"Gearslip needs Bluetooth and nearby Wi-Fi access for this.",
|
"Gearslip needs Bluetooth and nearby Wi-Fi access for this.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ object UpdateChecker {
|
|||||||
private const val CHANNEL = "updates"
|
private const val CHANNEL = "updates"
|
||||||
private const val NOTIFICATION_ID = 4100
|
private const val NOTIFICATION_ID = 4100
|
||||||
private const val DAY_MS = 24 * 60 * 60 * 1000L
|
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. */
|
/** 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. */
|
/** Set when the driver tapped Install but first had to allow installs from Gearslip. */
|
||||||
@Volatile private var installWhenAllowed = false
|
@Volatile private var installWhenAllowed = false
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ object WirelessLink {
|
|||||||
val input = DataInputStream(socket.inputStream)
|
val input = DataInputStream(socket.inputStream)
|
||||||
val output = socket.outputStream
|
val output = socket.outputStream
|
||||||
var target: Pair<String, Int>? = null
|
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 {
|
try {
|
||||||
while (!Thread.currentThread().isInterrupted) {
|
while (!Thread.currentThread().isInterrupted) {
|
||||||
val length = input.readUnsignedShort()
|
val length = input.readUnsignedShort()
|
||||||
@@ -201,8 +203,14 @@ object WirelessLink {
|
|||||||
val port = Wire.varint(f, 2)?.toInt() ?: 0
|
val port = Wire.varint(f, 2)?.toInt() ?: 0
|
||||||
log.i("<- StartRequest: projection at $ip:$port")
|
log.i("<- StartRequest: projection at $ip:$port")
|
||||||
target = ip to port
|
target = ip to port
|
||||||
send(output, MSG_INFO_REQUEST, ByteArray(0))
|
val join = waitingJoin
|
||||||
log.i("-> InfoRequest")
|
if (join != null) {
|
||||||
|
waitingJoin = null
|
||||||
|
join(ip, port)
|
||||||
|
} else {
|
||||||
|
send(output, MSG_INFO_REQUEST, ByteArray(0))
|
||||||
|
log.i("-> InfoRequest")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MSG_INFO_RESPONSE -> {
|
MSG_INFO_RESPONSE -> {
|
||||||
val f = Wire.fields(body)
|
val f = Wire.fields(body)
|
||||||
@@ -212,11 +220,15 @@ object WirelessLink {
|
|||||||
val bssid = text(3)
|
val bssid = text(3)
|
||||||
val security = Wire.varint(f, 4) ?: -1
|
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)"}")
|
log.i("<- InfoResponse: ssid=\"$ssid\" bssid=$bssid security=$security password=${if (password.isEmpty()) "none" else "(${password.length} chars)"}")
|
||||||
val (ip, port) = target ?: run {
|
val join = { ip: String, port: Int -> joinWifi(context, output, name, ssid, password, bssid, ip, port) }
|
||||||
log.w("InfoResponse before StartRequest: no address to connect to yet")
|
val known = target
|
||||||
"" to 0
|
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_PING -> send(output, MSG_PONG, body)
|
||||||
MSG_CONNECTION_STATUS, MSG_START_RESPONSE -> {
|
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: Bluetooth (any device) calls ${profileState(calls)}, media audio ${profileState(audio)}")
|
||||||
log.i(
|
log.i(
|
||||||
"probe: background data ${manager?.restrictBackgroundStatus} (1 off, 2 allowed, 3 restricted), " +
|
"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})") }
|
}.onFailure { log.w("probe: couldn't read the link (${it.message})") }
|
||||||
for (p in listOf(port, 5288, 5277, 80, 443).distinct()) {
|
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) {
|
private fun profileState(state: Int?) = when (state) {
|
||||||
BluetoothProfile.STATE_CONNECTED -> "connected"
|
BluetoothProfile.STATE_CONNECTED -> "connected"
|
||||||
BluetoothProfile.STATE_CONNECTING -> "connecting"
|
BluetoothProfile.STATE_CONNECTING -> "connecting"
|
||||||
@@ -408,6 +426,9 @@ object WirelessLink {
|
|||||||
SessionStatus.connecting("Wi-Fi: reaching $name")
|
SessionStatus.connecting("Wi-Fi: reaching $name")
|
||||||
lostNetwork = null
|
lostNetwork = null
|
||||||
var last: Exception? = 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 ->
|
repeat(TCP_TRIES) { attempt ->
|
||||||
if (run != tcpRun) {
|
if (run != tcpRun) {
|
||||||
log.i("TCP: stopping the tries on the old network - the car started over")
|
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")
|
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 {
|
val socket = when {
|
||||||
main != null -> main.socketFactory.createSocket()
|
main != null -> main.socketFactory.createSocket()
|
||||||
lostNetwork == net -> Socket()
|
lostNetwork == net || bindRefused -> Socket()
|
||||||
else -> net.socketFactory.createSocket()
|
else -> net.socketFactory.createSocket()
|
||||||
}
|
}
|
||||||
socket.tcpNoDelay = true
|
socket.tcpNoDelay = true
|
||||||
@@ -441,11 +462,23 @@ object WirelessLink {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
last = e
|
last = e
|
||||||
log.w("TCP: try ${attempt + 1} to $ip:$port failed: ${e.message}")
|
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)
|
if (attempt == 0) probe(context, net, ip, port)
|
||||||
Thread.sleep(1_000)
|
Thread.sleep(1_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.e("TCP: gave up on $ip:$port", last)
|
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(
|
SessionStatus.failed(
|
||||||
"Car's Wi-Fi joined, but no connection",
|
"Car's Wi-Fi joined, but no connection",
|
||||||
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
|
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ material = "1.14.0"
|
|||||||
constraintlayout = "2.2.1"
|
constraintlayout = "2.2.1"
|
||||||
zxing = "3.5.4"
|
zxing = "3.5.4"
|
||||||
okhttp = "5.4.0"
|
okhttp = "5.4.0"
|
||||||
commonsCompress = "1.28.0"
|
|
||||||
kotlinxSerializationJson = "1.11.0"
|
kotlinxSerializationJson = "1.11.0"
|
||||||
ossLicenses = "17.5.1"
|
ossLicenses = "17.5.1"
|
||||||
webkit = "1.16.0"
|
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" }
|
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
|
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
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" }
|
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" }
|
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" }
|
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||||
|
|||||||