mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Release builds ask in setup and Settings whether to send a short note when the app is opened and after each drive: the version, plus the Android version, phone model and cars people tick. Nothing runs in the background. Dev builds always send. The Cloudflare Worker in stats-worker/ stores the notes and serves a private dashboard with GitHub downloads alongside.
350 lines
15 KiB
Kotlin
350 lines
15 KiB
Kotlin
package app.seb3thehacker.gearslip
|
|
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.IntentFilter
|
|
import android.hardware.usb.UsbAccessory
|
|
import android.hardware.usb.UsbManager
|
|
import android.media.projection.MediaProjection
|
|
import android.media.projection.MediaProjectionManager
|
|
import android.os.Bundle
|
|
import android.os.ParcelFileDescriptor
|
|
import android.view.MotionEvent
|
|
import android.view.Surface
|
|
import android.view.WindowManager
|
|
import androidx.activity.ComponentActivity
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
|
import android.content.res.Configuration
|
|
import app.seb3thehacker.gearslip.audio.AudioCaptureService
|
|
import app.seb3thehacker.gearslip.audio.CarAudio
|
|
import app.seb3thehacker.gearslip.mirror.MirrorService
|
|
import app.seb3thehacker.gearslip.mirror.PhoneMirror
|
|
import app.seb3thehacker.gearslip.mirror.TouchRelayService
|
|
import app.seb3thehacker.gearslip.car.CarEnvironment
|
|
import app.seb3thehacker.gearslip.car.CarSettings
|
|
import app.seb3thehacker.gearslip.car.CarUi
|
|
import app.seb3thehacker.gearslip.car.Prefetch
|
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
|
import app.seb3thehacker.gearslip.ui.GearslipApp
|
|
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
|
import java.io.FileInputStream
|
|
import java.io.FileOutputStream
|
|
|
|
/**
|
|
* Hosts the phone UI and, for now, the car session itself.
|
|
*
|
|
* Launched by the system when the head unit switches the phone into accessory mode. The
|
|
* session lives in this activity, so [android:configChanges] in the manifest keeps a
|
|
* rotation or theme change from recreating it and dropping the connection.
|
|
*/
|
|
class GearslipActivity : ComponentActivity(), Projection {
|
|
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
|
|
|
|
private var descriptor: ParcelFileDescriptor? = null
|
|
private var runner: GearslipRunner? = null
|
|
private var screenProjector: ScreenProjector? = null
|
|
private var worker: Thread? = null
|
|
|
|
/** The encoder's input Surface, kept so the car's picture can be switched to the mirror. */
|
|
private var surface: Surface? = null
|
|
private var frameWidth = 0
|
|
private var frameHeight = 0
|
|
private var frameDensity = 0
|
|
|
|
/**
|
|
* Screen capture always begins with a consent dialog, which only an activity can show -
|
|
* so the car UI asks, and the request surfaces here on the phone.
|
|
*/
|
|
private val captureConsent = registerForActivityResult(StartActivityForResult()) { result ->
|
|
val data = result.data
|
|
if (result.resultCode == RESULT_OK && data != null) {
|
|
MirrorService.start(this, result.resultCode, data)
|
|
} else {
|
|
GearslipLog.w("screen capture consent declined")
|
|
}
|
|
}
|
|
|
|
/** Media audio needs the microphone permission (a platform rule for playback capture) first. */
|
|
private val audioPermission = registerForActivityResult(
|
|
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
|
) { granted ->
|
|
if (granted) askAudioCapture() else {
|
|
GearslipLog.w("audio: record permission declined")
|
|
CarAudio.denied()
|
|
}
|
|
}
|
|
|
|
private val audioConsent = registerForActivityResult(StartActivityForResult()) { result ->
|
|
val data = result.data
|
|
if (result.resultCode == RESULT_OK && data != null) {
|
|
AudioCaptureService.start(this, result.resultCode, data)
|
|
} else {
|
|
GearslipLog.w("audio: capture consent declined")
|
|
CarAudio.denied()
|
|
}
|
|
}
|
|
|
|
private val detachReceiver = object : BroadcastReceiver() {
|
|
override fun onReceive(context: Context?, intent: Intent?) {
|
|
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
|
|
GearslipLog.w("accessory detached")
|
|
closeAccessory()
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
|
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
|
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
|
}
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
// Location, calendar, the phone/contacts group, and the microphone are asked for one at
|
|
// a time by PermissionsSetupScreen instead of here, each only after the driver taps
|
|
// Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted,
|
|
// is both a bad first impression and unreliable: Android does not consistently show more
|
|
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
|
enableEdgeToEdge()
|
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
|
|
GearslipLog.init(this)
|
|
GearslipLog.installCrashHandler()
|
|
GearslipLog.i("Gearslip ready")
|
|
CarSettings.init(this)
|
|
AppSettings.applyGearslipEnabled(this)
|
|
// A capture that died without tidying up would leave notifications exposed to every
|
|
// later screen share. Nothing is capturing if the service is not running, so undo it.
|
|
if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) {
|
|
app.seb3thehacker.gearslip.audio.ScreenShareGuard.restore(this)
|
|
}
|
|
Prefetch.warm(this)
|
|
CarEnvironment.setPhoneTheme(resources.configuration)
|
|
|
|
setContent {
|
|
GearslipTheme {
|
|
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
|
|
}
|
|
}
|
|
|
|
PhoneMirror.onStartRequested = { runOnUiThread { requestCapture() } }
|
|
PhoneMirror.onTokenReady = { token -> runOnUiThread { showMirror(token) } }
|
|
PhoneMirror.onEnded = { runOnUiThread { showCarUi() } }
|
|
CarAudio.onConsentRequested = { runOnUiThread { requestAudioCapture() } }
|
|
|
|
registerReceiver(detachReceiver, IntentFilter(UsbManager.ACTION_USB_ACCESSORY_DETACHED), RECEIVER_NOT_EXPORTED)
|
|
handleIntent(intent)
|
|
}
|
|
|
|
// The manifest opts out of recreation on uiMode changes, so a light/dark switch on the phone
|
|
// arrives here, and the car UI follows it.
|
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
super.onConfigurationChanged(newConfig)
|
|
CarEnvironment.setPhoneTheme(newConfig)
|
|
}
|
|
|
|
// Throttled inside Prefetch, so this only does work if the last run was a while ago.
|
|
override fun onStart() {
|
|
super.onStart()
|
|
Prefetch.warm(this)
|
|
UsageStats.onAppOpened(this)
|
|
}
|
|
|
|
override fun onNewIntent(intent: Intent) {
|
|
super.onNewIntent(intent)
|
|
handleIntent(intent)
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
super.onDestroy()
|
|
runCatching { unregisterReceiver(detachReceiver) }
|
|
PhoneMirror.onStartRequested = null
|
|
PhoneMirror.onTokenReady = null
|
|
PhoneMirror.onEnded = null
|
|
CarAudio.onConsentRequested = null
|
|
CarAudio.release()
|
|
PhoneMirror.requestStop()
|
|
closeAccessory()
|
|
GearslipLog.flush()
|
|
}
|
|
|
|
/** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
|
|
private fun requestCallScreeningRole() {
|
|
val roleManager = getSystemService(android.app.role.RoleManager::class.java)
|
|
if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
|
|
GearslipLog.w("calls: call screening role unavailable on this phone")
|
|
return
|
|
}
|
|
if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
|
|
runCatching {
|
|
callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
|
|
}.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
|
|
}
|
|
|
|
// --- Media audio --------------------------------------------------------------------
|
|
|
|
private fun requestAudioCapture() {
|
|
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
|
askAudioCapture()
|
|
} else {
|
|
audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
|
}
|
|
}
|
|
|
|
private fun askAudioCapture() {
|
|
val manager = getSystemService(MediaProjectionManager::class.java)
|
|
runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) }
|
|
.onFailure {
|
|
GearslipLog.e("could not ask for audio capture", it)
|
|
CarAudio.denied()
|
|
}
|
|
}
|
|
|
|
// --- Phone mirroring ----------------------------------------------------------------
|
|
|
|
private fun requestCapture() {
|
|
val manager = getSystemService(MediaProjectionManager::class.java)
|
|
runCatching { captureConsent.launch(manager.createScreenCaptureIntent()) }
|
|
.onFailure { GearslipLog.e("could not ask for screen capture", it) }
|
|
}
|
|
|
|
/** A Surface has a single producer, so our own display must go before the mirror arrives. */
|
|
private fun showMirror(token: MediaProjection) {
|
|
val target = surface ?: return
|
|
screenProjector?.stop()
|
|
screenProjector = null
|
|
PhoneMirror.begin(token, target, frameWidth, frameHeight, frameDensity)
|
|
if (PhoneMirror.active.value) PhoneMirror.launchPending(this) else showCarUi()
|
|
}
|
|
|
|
private fun showCarUi() {
|
|
val target = surface ?: return
|
|
if (screenProjector != null) return
|
|
val projector = ScreenProjector(this).also { screenProjector = it }
|
|
projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() }
|
|
}
|
|
|
|
/**
|
|
* Sends a head-unit touch to the phone. Anything landing on the mirror's letterbox padding
|
|
* maps to nothing on the phone and is dropped rather than clamped onto a screen edge.
|
|
*/
|
|
private fun relayTouch(action: Int, x: Float, y: Float) {
|
|
val point = PhoneMirror.mapToPhone(this, x, y)
|
|
if (point == null) {
|
|
// The letterbox bars belong to no window, which makes them a safe way back: a tap
|
|
// on the padding leaves the mirror and returns the car to Gearslip's own UI.
|
|
if (action == MotionEvent.ACTION_UP) PhoneMirror.requestStop()
|
|
return
|
|
}
|
|
val relay = TouchRelayService.instance ?: return
|
|
when (action) {
|
|
MotionEvent.ACTION_DOWN -> relay.down(point.x, point.y)
|
|
MotionEvent.ACTION_MOVE -> relay.move(point.x, point.y)
|
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> relay.up(point.x, point.y)
|
|
}
|
|
}
|
|
|
|
private fun handleIntent(intent: Intent?) {
|
|
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
|
|
// Switched off in Settings: the system shouldn't route the car here at all, but if it
|
|
// does, leave the accessory alone.
|
|
if (!AppSettings.gearslipEnabled(this)) {
|
|
GearslipLog.i("accessory attached, but Gearslip is switched off - not connecting")
|
|
return
|
|
}
|
|
val accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java)
|
|
if (accessory == null) {
|
|
GearslipLog.e("attach intent carried no accessory")
|
|
SessionStatus.failed("Accessory missing", "The system reported a USB attach but did not include the accessory.")
|
|
return
|
|
}
|
|
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
|
|
start(accessory)
|
|
}
|
|
|
|
private fun start(accessory: UsbAccessory) {
|
|
// A fresh ATTACHED intent means the system wants a new session, whatever state the old
|
|
// one is in - closing first guarantees openAccessory() below never fights a descriptor
|
|
// this same process forgot to release. The old worker thread often isn't alive by this
|
|
// point anyway (its read loop already died when the head unit dropped the transport),
|
|
// but the accessory it opened was never explicitly closed, which is what actually blocks
|
|
// a re-open - a dead thread does not release the file descriptor on its own.
|
|
closeAccessory()
|
|
describe(accessory)
|
|
|
|
val manager = getSystemService(UsbManager::class.java)
|
|
val pfd = manager.openAccessory(accessory)
|
|
if (pfd == null) {
|
|
GearslipLog.e("openAccessory returned null - the session was claimed by someone else")
|
|
SessionStatus.failed("Accessory unavailable", "Another app claimed the USB accessory before Gearslip could open it.")
|
|
return
|
|
}
|
|
descriptor = pfd
|
|
|
|
val runner = GearslipRunner(
|
|
input = FileInputStream(pfd.fileDescriptor),
|
|
output = FileOutputStream(pfd.fileDescriptor),
|
|
identityProvider = { CertProvider.load(this) },
|
|
projection = this,
|
|
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
|
)
|
|
this.runner = runner
|
|
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
|
}
|
|
|
|
private fun describe(accessory: UsbAccessory) {
|
|
GearslipLog.i("accessory: manufacturer=${accessory.manufacturer} model=${accessory.model}")
|
|
GearslipLog.i(" version=${accessory.version} description=${accessory.description}")
|
|
}
|
|
|
|
private fun closeAccessory() {
|
|
runner?.stop()
|
|
SessionReport.print()
|
|
runCatching { descriptor?.close() }
|
|
descriptor = null
|
|
runner = null
|
|
}
|
|
|
|
// --- Projection ---------------------------------------------------------------------
|
|
|
|
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
|
|
runOnUiThread {
|
|
this.surface = surface
|
|
frameWidth = width
|
|
frameHeight = height
|
|
frameDensity = densityDpi
|
|
CarEnvironment.setDisplay(width, height, densityDpi)
|
|
CarEnvironment.start(this)
|
|
showCarUi()
|
|
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
|
|
runOnUiThread { screenProjector?.dispatchKey(code, down) }
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
|
// To the front of the main thread's queue: a touch waiting behind other work is lag the
|
|
// driver feels, while the work it jumps ahead of can wait a frame.
|
|
mainHandler.postAtFrontOfQueue {
|
|
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
|
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
|
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
|
}
|
|
}
|
|
|
|
override fun onProjectionStopped() {
|
|
runOnUiThread {
|
|
PhoneMirror.requestStop()
|
|
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
|
|
screenProjector?.stop()
|
|
screenProjector = null
|
|
surface = null
|
|
CarEnvironment.stop()
|
|
}
|
|
}
|
|
}
|