Compare commits

...
22 Commits
Author SHA1 Message Date
darken 5eb97cc966 Version bump (1.1.0) 2022-01-20 17:11:58 +01:00
darken e4f405ec65 Fix unit test workflow 2022-01-20 17:10:34 +01:00
darken f392df05f9 Filter emissions to only react to distinct items. 2022-01-20 17:01:05 +01:00
darken 0f8e441a92 Fix crash on profile service disconnect 2022-01-20 16:07:11 +01:00
darken edbc944724 Reduce logspam 2022-01-20 16:06:58 +01:00
darken 65eb92dcb1 Reduce logspam 2022-01-20 16:06:47 +01:00
darken 7df24f9c76 Fix device cards being shown even if Bluetooth is disabled. 2022-01-20 15:52:12 +01:00
darken 5d72d0116b Fix Bluetooth Profile triggering illegal state exception due to already resumed coroutine when the profile service disconnects. 2022-01-20 15:49:49 +01:00
darken 91a16f55d9 Add translation link to Crowdin and fix capod private repo links. 2022-01-20 15:20:54 +01:00
darken 9ae3516a83 Add fastlane files. 2022-01-20 15:12:19 +01:00
darken f3c20caa13 Add GitHub workflows 2022-01-20 15:12:05 +01:00
darken 680bcbad2a Update internal readme. 2022-01-20 15:11:52 +01:00
darken 5f60f2b5eb Fix lid detection tests 2022-01-20 15:11:37 +01:00
darken 7efe6619d2 Merge remote-tracking branch 'origin/main' into main 2022-01-19 19:40:43 +01:00
darken 1a3c2b3d23 Version bump (1.0.1) 2022-01-19 19:38:17 +01:00
darken 7cccff8a71 Remove NoPairedDevice card, too missleading. 2022-01-19 19:37:32 +01:00
darken 4a3e5056b6 Fix permission refreshing and add card if bluetooth is disabled. 2022-01-19 18:30:15 +01:00
Matthias Urhahn 16a880a92d Update README.md 2022-01-19 17:23:46 +01:00
Matthias Urhahn 79db1e2412 Update README.md 2022-01-19 17:20:11 +01:00
Matthias Urhahn b6e8d69eff Update README.md 2022-01-19 16:56:58 +01:00
darken 76c1b65b89 Add screenshots and banner 2022-01-19 16:54:37 +01:00
darken d7902bc86e Catch billing client exception 2022-01-19 15:31:26 +01:00
42 changed files with 508 additions and 269 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

+28
View File
@@ -0,0 +1,28 @@
name: Android CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
distribution: 'adopt'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew assembleDebug
- name: Run tests
run: ./gradlew testGplayDebugUnitTest testFossDebugUnitTest
@@ -0,0 +1,17 @@
name: "Validate Gradle Wrapper"
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
validation:
name: "Validation"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: gradle/wrapper-validation-action@v1
+24 -9
View File
@@ -1,11 +1,23 @@
<img src="https://github.com/d4rken/capod/raw/main/.assets/banner.png" width="400">
# Companion App for AirPods (CAPod) # Companion App for AirPods (CAPod)
![Build](https://github.com/d4rken/capod/actions/workflows/android.yml/badge.svg) ![Build](https://github.com/d4rken/capod/actions/workflows/android.yml/badge.svg)
[![Github All Releases](https://img.shields.io/github/downloads/d4rken/capod/total.svg)](https://github.com/d4rken/capod/releases/latest) [![Github All Releases](https://img.shields.io/github/downloads/d4rken/capod/total.svg)](https://github.com/d4rken/capod/releases/latest)
A companion app that adds support for AirPod specific features to Android. A companion app that adds support for AirPod specific features to Android:
Supported models: * Battery level for pods & cases.
* Charging status for pods & case.
* Additional infos about connection, microphone & case.
* Can receive and show all nearby devices.
* Ear detection with automatic play/pause.
* Automatically connect phone & airpods.
* Show popup when case is opened.
CAPod is ad-free. Some additional features require an in-app purchase.
Currently supported models:
* AirPods Gen1 * AirPods Gen1
* AirPods Gen2 * AirPods Gen2
@@ -18,17 +30,17 @@ Supported models:
* Beats X * Beats X
* Beats Flex * Beats Flex
## Support the project
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
* Help translate CAPod [on Crowdin](https://crowdin.com/project/airpod-companion)
## Download ## Download
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) * [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [GitHub](https://github.com/d4rken/capod/releases/latest) * [GitHub](https://github.com/d4rken/capod/releases/latest)
## Support the project
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* Help translate CAPod [on Crowdin](https://crowdin.com/project/capod)
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
## Get help ## Get help
* [Github Issues](https://github.com/d4rken/capod/issues) * [Github Issues](https://github.com/d4rken/capod/issues)
@@ -36,6 +48,8 @@ Supported models:
## Screenshots ## Screenshots
<img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/4.png" width="200">
## Thanks to ## Thanks to
* The [OpenPods](https://github.com/adolfintel/OpenPods) project, * The [OpenPods](https://github.com/adolfintel/OpenPods) project,
@@ -49,8 +63,9 @@ Supported models:
CAPod's code is available under a GPL v3 license, this excludes: CAPod's code is available under a GPL v3 license, this excludes:
* CAPod icons, logos, mascots and marketing materials. * CAPod icons, logos, mascots and marketing materials/assets.
* CAPod animations and videos. * CAPod animations and videos.
* CAPod documentation. * CAPod documentation.
* Google Play screenshots. * Google Play screenshots.
* Google Play texts & descriptions. * Google Play texts & descriptions.
* Translations.
@@ -14,10 +14,7 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -112,8 +109,11 @@ class BillingClientConnectionProvider @Inject constructor(
delay(3000 * attempt) delay(3000 * attempt)
true true
} }
.catch {
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
}
companion object { companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientProvider") val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider")
} }
} }
@@ -17,19 +17,12 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
@Singleton @Singleton
class BluetoothManager2 @Inject constructor( class BluetoothManager2 @Inject constructor(
@@ -68,90 +61,96 @@ class BluetoothManager2 @Inject constructor(
awaitClose { context.unregisterReceiver(receiver) } awaitClose { context.unregisterReceiver(receiver) }
} }
suspend fun getBluetoothProfile( fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
profile: Int = BluetoothProfile.HEADSET
): BluetoothProfile2 = withContext(dispatcherProvider.IO) {
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" } log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
suspendCancellableCoroutine { var profileProxy: BluetoothProfile2? = null
val connectionState = AtomicBoolean(false) manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
connectionState.set(true)
BluetoothProfile2(
profileType = profile,
profileProxy = proxy,
isConnectedAtomic = connectionState
).run { it.resume(this) }
}
override fun onServiceDisconnected(profile: Int) { profileProxy = BluetoothProfile2(
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" } profileType = profile,
connectionState.set(false) profileProxy = proxy,
it.cancel(IOException("BluetoothProfile service disconnected (profile=$profile)")) ).also { trySend(it) }
} }
}, profile) override fun onServiceDisconnected(profile: Int) {
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
}
}, profile)
awaitClose {
log(TAG) { "Closing BluetoothProfile: $profileProxy" }
profileProxy?.let {
manager.adapter.closeProfileProxy(it.profileType, it.proxy)
}
} }
} }
private fun connectedDevicesForProfile(profile: Int): Flow<Set<BluetoothDevice>> = callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(getBluetoothProfile(profile).connectedDevices)
val filter = IntentFilter().apply { private fun monitorDevicesForProfile(
addAction(BluetoothDevice.ACTION_ACL_CONNECTED) profile: Int = BluetoothProfile.HEADSET
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) ): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
} callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(bluetoothProfile.connectedDevices)
val handlerThread = HandlerThread("BluetoothEventReceiver").apply { val filter = IntentFilter().apply {
start() addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
} addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
val handler = Handler(handlerThread.looper) }
val receiver: BroadcastReceiver = object : BroadcastReceiver() { val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
override fun onReceive(context: Context, intent: Intent) { start()
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" } }
val action = intent.action val handler = Handler(handlerThread.looper)
if (action == null) {
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
return
}
this@callbackFlow.launch { val receiver: BroadcastReceiver = object : BroadcastReceiver() {
val currentDevices = getBluetoothProfile(profile).connectedDevices override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
val action = intent.action
if (action == null) {
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
return
}
when (action) { this@callbackFlow.launch {
BluetoothDevice.ACTION_ACL_CONNECTED -> { val currentDevices = bluetoothProfile.connectedDevices
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device)) when (action) {
} BluetoothDevice.ACTION_ACL_CONNECTED -> {
BluetoothDevice.ACTION_ACL_DISCONNECTED -> { log(TAG) { "Adding $device to current devices $currentDevices" }
log(TAG) { "Removing $device from current devices $currentDevices" } trySend(currentDevices.plus(device))
trySend(currentDevices.minus(device)) }
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
}
} }
} }
} }
} }
} context.registerReceiver(receiver, filter, null, handler)
context.registerReceiver(receiver, filter, null, handler)
awaitClose { awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." } log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver) context.unregisterReceiver(receiver)
}
} }
} }
fun connectedDevices( fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice>> = isBluetoothEnabled ): Flow<List<BluetoothDevice>> = isBluetoothEnabled
.flatMapLatest { connectedDevicesForProfile(BluetoothProfile.HEADSET) } .flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
.map { devices -> .map { devices ->
devices.filter { device -> devices.filter { device ->
featureFilter.any { feature -> featureFilter.any { feature ->
@@ -162,21 +161,23 @@ class BluetoothManager2 @Inject constructor(
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = try { suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
log(TAG) { "Nudging Android connection to $device" } try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod( val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java "connect", BluetoothDevice::class.java
).apply { isAccessible = true } ).apply { isAccessible = true }
connectMethod.invoke(getBluetoothProfile().profile, device) connectMethod.invoke(bluetoothProfile.proxy, device)
log(TAG) { "Nudged connection to $device" } log(TAG) { "Nudged connection to $device" }
true true
} catch (e: Exception) { } catch (e: Exception) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e) Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false false
} }
}.first()
companion object { companion object {
private val TAG = logTag("Bluetooth", "Manager2") private val TAG = logTag("Bluetooth", "Manager2")
@@ -2,23 +2,15 @@ package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile
import java.util.concurrent.atomic.AtomicBoolean
data class BluetoothProfile2( data class BluetoothProfile2(
private val profileType: Int, internal val profileType: Int,
private val profileProxy: BluetoothProfile, private val profileProxy: BluetoothProfile,
private val isConnectedAtomic: AtomicBoolean,
) { ) {
val profile: BluetoothProfile val proxy: BluetoothProfile
get() { get() = profileProxy
if (!isConnected) throw IllegalStateException("Proxy is not connected")
return profileProxy
}
val connectedDevices: Set<BluetoothDevice> val connectedDevices: Set<BluetoothDevice>
get() = profile.connectedDevices.toSet() get() = proxy.connectedDevices.toSet()
val isConnected: Boolean
get() = isConnectedAtomic.get()
} }
@@ -1,20 +1,43 @@
package eu.darken.capod.main.core package eu.darken.capod.main.core
import android.content.Context import android.content.Context
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isRequired import eu.darken.capod.common.permissions.isRequired
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import java.util.*
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Reusable @Singleton
class PermissionTool @Inject constructor( class PermissionTool @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings, private val generalSettings: GeneralSettings,
) { ) {
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
suspend fun missingPermissions(): Set<Permission> = Permission.values() fun recheck() {
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || generalSettings.monitorMode.value == MonitorMode.ALWAYS } log(TAG) { "recheck()" }
.filter { it.isRequired(context) } permissionCheckTrigger.value = UUID.randomUUID()
.toSet() }
val missingPermissions: Flow<Set<Permission>> = combine(
permissionCheckTrigger,
generalSettings.monitorMode.flow,
) { _, monitorMode ->
Permission.values()
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
.filter { it.isRequired(context) }
.toSet()
}
.onEach { log(TAG) { "Missing permission: $it" } }
companion object {
private val TAG = logTag("PermissionTool")
}
} }
@@ -11,8 +11,8 @@ import eu.darken.capod.common.lists.differ.setupDiffer
import eu.darken.capod.common.lists.modular.ModularAdapter import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.DataBinderMod import eu.darken.capod.common.lists.modular.mods.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.main.ui.overview.cards.NoPairedDeviceCardVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.pods.BasicSingleApplePodsCardVH import eu.darken.capod.main.ui.overview.cards.pods.BasicSingleApplePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.DualApplePodsCardVH import eu.darken.capod.main.ui.overview.cards.pods.DualApplePodsCardVH
@@ -33,8 +33,8 @@ class OverviewAdapter @Inject constructor() :
modules.add(TypedVHCreatorMod({ data[it] is SingleApplePodsCardVH.Item }) { SingleApplePodsCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is SingleApplePodsCardVH.Item }) { SingleApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BasicSingleApplePodsCardVH.Item }) { BasicSingleApplePodsCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is BasicSingleApplePodsCardVH.Item }) { BasicSingleApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is NoPairedDeviceCardVH.Item }) { NoPairedDeviceCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
} }
override fun getItemCount(): Int = data.size override fun getItemCount(): Int = data.size
@@ -70,8 +70,10 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
} }
} }
vm.listItems.observe2(ui) { vm.listItems.observe2(ui) { adapter.update(it) }
adapter.update(it)
vm.workerAutolaunch.observe2 {
// While UI is active, subscribe to the autolaunch routine
} }
vm.requestPermissionEvent.observe2(ui) { vm.requestPermissionEvent.observe2(ui) {
@@ -8,6 +8,7 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.livedata.SingleLiveEvent import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.navigation.navVia import eu.darken.capod.common.navigation.navVia
import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.Permission
@@ -16,8 +17,8 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.main.ui.overview.cards.NoPairedDeviceCardVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.pods.* import eu.darken.capod.main.ui.overview.cards.pods.*
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
@@ -26,7 +27,6 @@ import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -57,9 +57,7 @@ class OverviewFragmentVM @Inject constructor(
} }
} }
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID()) val workerAutolaunch: LiveData<Unit> = permissionTool.missingPermissions
private val requiredPermissions: Flow<Collection<Permission>> = permissionCheckTrigger
.map { permissionTool.missingPermissions() }
.onEach { .onEach {
if (it.isNotEmpty()) { if (it.isNotEmpty()) {
log(TAG) { "Missing permissions: $it" } log(TAG) { "Missing permissions: $it" }
@@ -76,26 +74,24 @@ class OverviewFragmentVM @Inject constructor(
monitorControl.startMonitor() monitorControl.startMonitor()
} }
} }
.map { Unit }
.asLiveData2()
val requestPermissionEvent = SingleLiveEvent<Permission>() val requestPermissionEvent = SingleLiveEvent<Permission>()
private val pods: Flow<List<PodDevice>> = requiredPermissions private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
.flatMapLatest { permissions -> .flatMapLatest { permissions ->
if (permissions.isEmpty()) { if (permissions.isNotEmpty()) {
generalSettings.showAll.flow return@flatMapLatest flowOf(emptyList())
.flatMapLatest { showAll -> }
if (showAll) {
podMonitor.devices generalSettings.showAll.flow.flatMapLatest { showAll ->
} else { if (showAll) {
podMonitor.mainDevice.map { mainDevice -> podMonitor.devices
mainDevice?.let { listOf(it) } ?: emptyList() } else {
} podMonitor.mainDevice.map { mainDevice ->
} mainDevice?.let { listOf(it) } ?: emptyList()
} }
} else {
channelFlow {
send(emptyList())
awaitClose()
} }
} }
} }
@@ -103,61 +99,65 @@ class OverviewFragmentVM @Inject constructor(
val listItems: LiveData<List<OverviewAdapter.Item>> = combine( val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
updateTicker, updateTicker,
requiredPermissions, permissionTool.missingPermissions,
pods, pods,
debugSettings.isDebugModeEnabled.flow, debugSettings.isDebugModeEnabled.flow,
generalSettings.showAll.flow, generalSettings.showAll.flow,
) { tick, permissions, pods, isDebugMode, showAll -> bluetoothManager.isBluetoothEnabled,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled ->
val items = mutableListOf<OverviewAdapter.Item>() val items = mutableListOf<OverviewAdapter.Item>()
val mainPod = podMonitor.mainDevice.first() val mainPod = podMonitor.mainDevice.first()
val now = Instant.now()
pods
.map {
when (it) {
is DualApplePods -> DualApplePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
is SingleApplePods -> SingleApplePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
is BasicSingleApplePods -> BasicSingleApplePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
else -> UnknownPodDeviceCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
}
}
.run { items.addAll(this) }
permissions permissions
.map { .map { perm ->
PermissionCardVH.Item( PermissionCardVH.Item(
permission = it, permission = perm,
onRequest = { requestPermissionEvent.postValue(it) } onRequest = { requestPermissionEvent.postValue(it) }
) )
} }
.run { items.addAll(this) } .run { items.addAll(this) }
if (!showAll && items.none { it is PodDeviceVH.Item } && permissions.isEmpty()) { if (permissions.isEmpty()) {
NoPairedDeviceCardVH.Item { if (!isBluetoothEnabled) {
generalSettings.showAll.value = true items.add(0, BluetoothDisabledVH.Item)
}.run { items.add(this) } } else if (mainPod == null) {
} else if (showAll && mainPod == null && permissions.isEmpty()) { items.add(0, MissingMainDeviceVH.Item)
items.add(0, MissingMainDeviceVH.Item) }
}
if (permissions.isEmpty() && isBluetoothEnabled) {
pods
.map {
val now = Instant.now()
when (it) {
is DualApplePods -> DualApplePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
is SingleApplePods -> SingleApplePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
is BasicSingleApplePods -> BasicSingleApplePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
else -> UnknownPodDeviceCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
)
}
}
.run { items.addAll(this) }
} }
items items
@@ -166,7 +166,7 @@ class OverviewFragmentVM @Inject constructor(
.asLiveData2() .asLiveData2()
fun onPermissionResult(granted: Boolean) { fun onPermissionResult(granted: Boolean) {
if (granted) permissionCheckTrigger.value = UUID.randomUUID() if (granted) permissionTool.recheck()
} }
fun goToSettings() = launch { fun goToSettings() = launch {
@@ -4,29 +4,27 @@ import android.view.ViewGroup
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.lists.binding import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.databinding.OverviewNopaireddeviceItemBinding import eu.darken.capod.databinding.OverviewBluetoothDisabledItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter import eu.darken.capod.main.ui.overview.OverviewAdapter
class NoPairedDeviceCardVH(parent: ViewGroup) : class BluetoothDisabledVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<NoPairedDeviceCardVH.Item, OverviewNopaireddeviceItemBinding>( OverviewAdapter.BaseVH<BluetoothDisabledVH.Item, OverviewBluetoothDisabledItemBinding>(
R.layout.overview_nopaireddevice_item, R.layout.overview_bluetooth_disabled_item,
parent parent
) { ) {
override val viewBinding = lazy { override val viewBinding = lazy {
OverviewNopaireddeviceItemBinding.bind(itemView) OverviewBluetoothDisabledItemBinding.bind(itemView)
} }
override val onBindData: OverviewNopaireddeviceItemBinding.( override val onBindData: OverviewBluetoothDisabledItemBinding.(
item: Item, item: Item,
payloads: List<Any> payloads: List<Any>
) -> Unit = binding(payload = true) { item -> ) -> Unit = binding(payload = true) { item ->
showAllAction.setOnClickListener { item.onShowAll() }
} }
data class Item( object Item : OverviewAdapter.Item {
val onShowAll: () -> Unit
) : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong() override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?) override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
@@ -32,24 +32,25 @@ class PodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>() private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val cacheLock = Mutex() private val cacheLock = Mutex()
val devices: Flow<List<PodDevice>> = generalSettings.scannerMode.flow val devices: Flow<List<PodDevice>> = bluetoothManager.isBluetoothEnabled
.flatMapLatest { scannerMode -> .flatMapLatest { isBluetoothEnabled ->
bluetoothManager.isBluetoothEnabled.flatMapLatest { isBluetoothEnabled -> if (isBluetoothEnabled) {
if (isBluetoothEnabled) { generalSettings.scannerMode.flow
bleScanner.scan(scannerMode = scannerMode) } else {
} else { log(TAG, WARN) { "Bluetooth is currently disabled" }
log(TAG, WARN) { "Bluetooth is current disabled" } emptyFlow()
emptyFlow()
}
} }
} }
.flatMapLatest { bleScanner.scan(scannerMode = it) }
.map { result -> .map { result ->
// For each address we only want the newest result, upstream may batch data // For each address we only want the newest result, upstream may batch data
result.groupBy { it.address } result.groupBy { it.address }
.values .values
.map { sameAdrDevs -> .map { sameAdrDevs ->
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!! val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
log(TAG, VERBOSE) { "Discarding stale results: ${sameAdrDevs.minus(newest)}" } sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
}
newest newest
} }
} }
@@ -76,7 +76,7 @@ class MonitorWorker @AssistedInject constructor(
} }
private suspend fun doDoWork() { private suspend fun doDoWork() {
val permissionsMissingOnStart = permissionTool.missingPermissions() val permissionsMissingOnStart = permissionTool.missingPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) { if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" } log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
return return
@@ -99,7 +99,7 @@ class MonitorWorker @AssistedInject constructor(
generalSettings.monitorMode.flow generalSettings.monitorMode.flow
.flatMapLatest { monitorMode -> .flatMapLatest { monitorMode ->
val missingPermsFlow = permissionTool.missingPermissions() val missingPermsFlow = permissionTool.missingPermissions.first()
if (missingPermsFlow.isNotEmpty()) { if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing for $monitorMode: $missingPermsFlow" } log(TAG, WARN) { "Aborting, permissions are missing for $monitorMode: $missingPermsFlow" }
workerScope.coroutineContext.cancelChildren() workerScope.coroutineContext.cancelChildren()
@@ -1,7 +1,6 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import android.content.Context import android.content.Context
import android.util.Range
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
@@ -98,7 +97,7 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
val caseLidState: LidState val caseLidState: LidState
get() { get() {
return LidState.values().firstOrNull { it.rawRange.contains(rawCaseLidState) } ?: LidState.UNKNOWN return LidState.values().firstOrNull { it.rawRange.contains(rawCaseLidState.toInt()) } ?: LidState.UNKNOWN
} }
/** /**
@@ -109,13 +108,11 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
* They reset after some time to their start values. * They reset after some time to their start values.
* The upper limits are not the maximums but are only reached if playing with the case. * The upper limits are not the maximums but are only reached if playing with the case.
*/ */
enum class LidState(val rawRange: Range<UByte>) { enum class LidState(val rawRange: IntRange) {
OPEN(0x30, 0x37), OPEN(0x30..0x37),
CLOSED(0x38, 0x3F), CLOSED(0x38..0x3F),
NOT_IN_CASE(0x00, 0x03), NOT_IN_CASE(0x00..0x03),
UNKNOWN(0xFF, 0xFF); UNKNOWN(0xFF..0xFF);
constructor(vararg raw: Int) : this(Range(raw[0].toUByte(), (raw[1].toUByte())))
} }
val deviceColor: DeviceColor val deviceColor: DeviceColor
@@ -1,6 +1,8 @@
package eu.darken.capod.reaction.autoconnect package eu.darken.capod.reaction.autoconnect
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
@@ -22,29 +24,36 @@ class AutoConnect @Inject constructor(
) { ) {
fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() } .flatMapLatest { isAutoConnectEnabled ->
.filterNotNull() if (isAutoConnectEnabled) {
.distinctUntilChanged() combine(
.map { podDevice -> bluetoothManager.connectedDevices().distinctUntilChanged(),
log(TAG) { "mainPodDevice is $podDevice" } podMonitor.mainDevice.filterNotNull().distinctUntilChangedBy { it.rawDataHex },
) { connectedDevices, mainDevice ->
connectedDevices to mainDevice
}
} else {
emptyFlow()
}
}
.map { (connectedDevices, mainDevice) ->
log(TAG, VERBOSE) { "mainPodDevice is $mainDevice" }
val mainDeviceAddr = generalSettings.mainDeviceAddress.value val mainDeviceAddr = generalSettings.mainDeviceAddress.value
if (mainDeviceAddr.isNullOrEmpty()) { if (mainDeviceAddr.isNullOrEmpty()) {
log(TAG) { "mainDeviceAddress is null" } log(TAG, WARN) { "mainDeviceAddress is null" }
return@map return@map
} else {
log(TAG) { "mainDeviceAddress is $mainDeviceAddr" }
} }
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr } val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr }
if (bondedDevice == null) { if (bondedDevice == null) {
log(TAG) { "No bonded device matches $mainDeviceAddr" } log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" }
return@map return@map
} else { } else {
log(TAG) { "Found target device: $bondedDevice" } log(TAG, VERBOSE) { "Found target device: $bondedDevice" }
} }
val isAlreadyConnected = bluetoothManager.connectedDevices().first().any { val isAlreadyConnected = connectedDevices.any {
it.address == bondedDevice.address it.address == bondedDevice.address
} }
@@ -57,12 +66,12 @@ class AutoConnect @Inject constructor(
log(TAG) { "Checking condition $condition" } log(TAG) { "Checking condition $condition" }
val conditionFulfilled = when (condition) { val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (podDevice) { AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualApplePods -> podDevice.caseLidState == DualApplePods.LidState.OPEN is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true else -> true
} }
AutoConnectCondition.IN_EAR -> when (podDevice) { AutoConnectCondition.IN_EAR -> when (mainDevice) {
is HasEarDetection -> podDevice.isBeingWorn is HasEarDetection -> mainDevice.isBeingWorn
else -> true else -> true
} }
} }
@@ -11,10 +11,7 @@ import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.popup.ui.PopUpNotification import eu.darken.capod.reaction.popup.ui.PopUpNotification
import eu.darken.capod.reaction.settings.ReactionSettings import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,12 +23,18 @@ class PopUpReaction @Inject constructor(
) { ) {
fun monitor(): Flow<Unit> = reactionSettings.showPopUpOnCaseOpen.flow fun monitor(): Flow<Unit> = reactionSettings.showPopUpOnCaseOpen.flow
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() } .flatMapLatest { isEnabled ->
if (isEnabled) {
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex }
} else {
emptyFlow()
}
}
.withPrevious() .withPrevious()
.setupCommonEventHandlers(TAG) { "monitor" } .setupCommonEventHandlers(TAG) { "monitor" }
.map { (previous, current) -> .map { (previous, current) ->
if (previous is DualApplePods? && current is DualApplePods) { if (previous is DualApplePods? && current is DualApplePods) {
log(TAG) { log(TAG, VERBOSE) {
val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) } val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) }
val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) } val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) }
"previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})" "previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})"
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12.87,15.07l-2.54,-2.51 0.03,-0.03c1.74,-1.94 2.98,-4.17 3.71,-6.53L17,6L17,4h-7L10,2L8,2v2L1,4v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
</vector>
@@ -14,42 +14,30 @@
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_label" android:id="@+id/bluetooth_label"
style="@style/TextAppearance.MaterialComponents.Body1" style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="@string/overview_bluetooth_disabled_label"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent" />
android:text="@string/overview_nopaireddevices_label" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_description" android:id="@+id/bluetooth_description"
style="@style/TextAppearance.MaterialComponents.Body2" style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:layout_marginHorizontal="16dp" android:layout_marginBottom="16dp"
android:layout_height="wrap_content" android:text="@string/overview_bluetooth_disabled_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_label"
android:text="@string/overview_nopaireddevices_description" />
<com.google.android.material.button.MaterialButton
android:id="@+id/show_all_action"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginTop="16dp"
android:text="@string/settings_showall_label"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_description" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/bluetooth_label" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
+5 -3
View File
@@ -71,9 +71,7 @@
<string name="pods_microphone_label">Microphone</string> <string name="pods_microphone_label">Microphone</string>
<string name="pods_yours">Yours</string> <string name="pods_yours">Yours</string>
<string name="overview_nopaireddevices_label">No paired device connected.</string> <string name="overview_nomaindevice_label">No primary device</string>
<string name="overview_nopaireddevices_description">Connect a paired device or enable the \'Show all\' option.</string>
<string name="overview_nomaindevice_label">Missing main device</string>
<string name="overview_nomaindevice_description">All detected devices are unlikely to be yours. Power on and connect your device or adjust the settings.</string> <string name="overview_nomaindevice_description">All detected devices are unlikely to be yours. Power on and connect your device or adjust the settings.</string>
<string name="settings_label">Settings</string> <string name="settings_label">Settings</string>
@@ -146,4 +144,8 @@
<string name="settings_popup_caseopen_label">Show popup</string> <string name="settings_popup_caseopen_label">Show popup</string>
<string name="settings_popup_caseopen_description">Show a popup when the device case is opened (experimental).</string> <string name="settings_popup_caseopen_description">Show a popup when the device case is opened (experimental).</string>
<string name="notification_channel_reaction_popup_label">Popup device reactions</string> <string name="notification_channel_reaction_popup_label">Popup device reactions</string>
<string name="overview_bluetooth_disabled_label">Bluetooth is disabled</string>
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;).</string>
<string name="help_translate_label">Translation</string>
<string name="help_translate_description">Help translate this into your favorite language.</string>
</resources> </resources>
+20 -10
View File
@@ -21,6 +21,15 @@
<PreferenceCategory android:title="@string/settings_category_other_label"> <PreferenceCategory android:title="@string/settings_category_other_label">
<Preference
android:icon="@drawable/ic_baseline_book_24"
app:summary="@string/settings_privacy_policy_desc"
app:title="@string/settings_privacy_policy_label">
<intent
android:action="android.intent.action.VIEW"
android:data="https://github.com/d4rken/capod-public/blob/main/PRIVACY_POLICY.md" />
</Preference>
<Preference <Preference
android:icon="@drawable/ic_changelog_onsurface" android:icon="@drawable/ic_changelog_onsurface"
android:key="core.changelog" android:key="core.changelog"
@@ -28,7 +37,17 @@
app:summary="v?.?.?"> app:summary="v?.?.?">
<intent <intent
android:action="android.intent.action.VIEW" android:action="android.intent.action.VIEW"
android:data="https://github.com/d4rken/capod/releases/latest" /> android:data="https://github.com/d4rken/capod-public/releases/latest" />
</Preference>
<Preference
android:icon="@drawable/ic_baseline_translate_24"
android:key="core.translation"
android:title="@string/help_translate_label"
app:summary="@string/help_translate_description">
<intent
android:action="android.intent.action.VIEW"
android:data="https://crowdin.com/project/capod" />
</Preference> </Preference>
<Preference <Preference
@@ -37,14 +56,5 @@
app:summary="@string/general_thank_you_label" app:summary="@string/general_thank_you_label"
app:title="@string/settings_acknowledgements_label" /> app:title="@string/settings_acknowledgements_label" />
<Preference
android:icon="@drawable/ic_baseline_book_24"
app:summary="@string/settings_privacy_policy_desc"
app:title="@string/settings_privacy_policy_label">
<intent
android:action="android.intent.action.VIEW"
android:data="https://github.com/d4rken/capod/blob/main/PRIVACY_POLICY.md" />
</Preference>
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>
+1 -1
View File
@@ -8,7 +8,7 @@
android:title="@string/issue_tracker_label"> android:title="@string/issue_tracker_label">
<intent <intent
android:action="android.intent.action.VIEW" android:action="android.intent.action.VIEW"
android:data="https://github.com/d4rken/capod/issues" /> android:data="https://github.com/d4rken/capod-public/issues" />
</Preference> </Preference>
<Preference <Preference
android:icon="@drawable/ic_discord_onsurface" android:icon="@drawable/ic_discord_onsurface"
@@ -188,7 +188,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
// Lid just closed // Lid just closed
create<DualApplePods>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") { create<DualApplePods>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") {
// 39 0011 1001 // 39 0011 1001
caseLidState shouldBe DualApplePods.LidState.UNKNOWN caseLidState shouldBe DualApplePods.LidState.CLOSED
} }
// Lid closed // Lid closed
create<DualApplePods>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") { create<DualApplePods>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") {
+1 -1
View File
@@ -7,7 +7,7 @@ buildscript {
'version' : [ 'version' : [
'major': 1, 'major': 1,
'minor': 0, 'minor': 1,
'patch': 0, 'patch': 0,
'build': 0, 'build': 0,
], ],
+2
View File
@@ -0,0 +1,2 @@
json_key_file "~/.fastlaneconfig/androiddev-console-darken_development-4f6965ff0eda.json" # Path to the json secret file - Follow https://github.com/fastlane/supply#setup to get one
package_name "eu.darken.capod" # e.g. com.krausefx.app
+63
View File
@@ -0,0 +1,63 @@
# Customise this file, documentation can be found here:
# https://docs.fastlane.tools/actions/
# All available actions: https://docs.fastlane.tools/actions
# can also be listed using the `fastlane actions` command
# Change the syntax highlighting to Ruby
# All lines starting with a # are ignored when running `fastlane`
# If you want to automatically update fastlane if a new version is available:
# update_fastlane
# This is the minimum version number required.
# Update this, if you use features of a newer version
fastlane_version "2.137.0"
default_platform :android
platform :android do
before_all do
#ensure_git_status_clean
end
lane :beta do
ensure_git_branch(branch: 'dev')
git_pull
gradle(task: 'clean assembleRelease')
supply(track: 'beta')
end
lane :production do
ensure_git_branch(branch: 'master')
git_pull
gradle(task: 'clean assembleRelease')
supply(track: 'rollout', rollout: '0.1')
end
lane :listing_only do
sh "bash ./remove_unsupported_languages.sh"
supply(
track: 'production',
skip_upload_apk: 'true',
skip_upload_images: 'true',
skip_upload_screenshots: 'true',
skip_upload_changelogs: true,
skip_upload_aab: 'true'
)
end
after_all do |lane|
# This block is called, only if the executed lane was successful
# slack(
# message: "Successfully deployed new App Update."
# )
end
error do |lane, exception|
# slack(
# message: exception.message,
# success: false
# )
end
end
+46
View File
@@ -0,0 +1,46 @@
fastlane documentation
================
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```
xcode-select --install
```
Install _fastlane_ using
```
[sudo] gem install fastlane -NV
```
or alternatively using `brew cask install fastlane`
# Available Actions
## Android
### android beta
```
fastlane android beta
```
### android production
```
fastlane android production
```
### android listing_only
```
fastlane android listing_only
```
----
This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run. More
information about fastlane can be found on [fastlane.tools](https://fastlane.tools). The documentation of fastlane can
be found on [docs.fastlane.tools](https://docs.fastlane.tools).
@@ -0,0 +1,2 @@
v1.0.0:
- Initial release.
@@ -0,0 +1,17 @@
CAPod is a companion app for AirPods.
Features:
* Battery level for pods & cases.
* Charging status for pods & case.
* Additional infos about connection, microphone & case.
* Can receive and show all nearby devices.
* Ear detection with automatic play/pause.
* Automatically connect phone & airpods.
* Show popup when case is opened.
CAPod is ad-free. Some additional features require an in-app purchase.
Most popular AirPods and Beats devices are supported.
If your device is similar to AirPods but not yet supported, send me a quick mail.
Got a cool idea for a new feature? Reach out!
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

@@ -0,0 +1 @@
CAPod is a companion app for AirPods on Android.
@@ -0,0 +1 @@
CAPod - Companion for AirPods
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
echo "Removing unsupported languages..."
echo `pwd`
rm -rv ./metadata/android/es-AR
rm -rv ./metadata/android/sc-IT
rm -rv ./metadata/android/sq-AL
rm -rv ./metadata/android/uz
rm -rv ./metadata/android/pcm-NG
rm -rv ./metadata/android/tl-PH
find ./metadata/android -empty -type d -delete
exit 0