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)
![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)
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 Gen2
@@ -18,17 +30,17 @@ Supported models:
* Beats X
* 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
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [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
* [Github Issues](https://github.com/d4rken/capod/issues)
@@ -36,6 +48,8 @@ Supported models:
## 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
* 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 icons, logos, mascots and marketing materials.
* CAPod icons, logos, mascots and marketing materials/assets.
* CAPod animations and videos.
* CAPod documentation.
* Google Play screenshots.
* Google Play texts & descriptions.
* Translations.
@@ -14,10 +14,7 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@@ -112,8 +109,11 @@ class BillingClientConnectionProvider @Inject constructor(
delay(3000 * attempt)
true
}
.catch {
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
}
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.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@Singleton
class BluetoothManager2 @Inject constructor(
@@ -68,90 +61,96 @@ class BluetoothManager2 @Inject constructor(
awaitClose { context.unregisterReceiver(receiver) }
}
suspend fun getBluetoothProfile(
profile: Int = BluetoothProfile.HEADSET
): BluetoothProfile2 = withContext(dispatcherProvider.IO) {
fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
suspendCancellableCoroutine {
val connectionState = AtomicBoolean(false)
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
connectionState.set(true)
BluetoothProfile2(
profileType = profile,
profileProxy = proxy,
isConnectedAtomic = connectionState
).run { it.resume(this) }
}
var profileProxy: BluetoothProfile2? = null
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
override fun onServiceDisconnected(profile: Int) {
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
connectionState.set(false)
it.cancel(IOException("BluetoothProfile service disconnected (profile=$profile)"))
}
profileProxy = BluetoothProfile2(
profileType = profile,
profileProxy = proxy,
).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 {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
}
private fun monitorDevicesForProfile(
profile: Int = BluetoothProfile.HEADSET
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(bluetoothProfile.connectedDevices)
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
start()
}
val handler = Handler(handlerThread.looper)
val filter = IntentFilter().apply {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
}
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
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
}
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
start()
}
val handler = Handler(handlerThread.looper)
this@callbackFlow.launch {
val currentDevices = getBluetoothProfile(profile).connectedDevices
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
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) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device))
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
this@callbackFlow.launch {
val currentDevices = bluetoothProfile.connectedDevices
when (action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(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 {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver)
awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver)
}
}
}
fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice>> = isBluetoothEnabled
.flatMapLatest { connectedDevicesForProfile(BluetoothProfile.HEADSET) }
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
.map { devices ->
devices.filter { device ->
featureFilter.any { feature ->
@@ -162,21 +161,23 @@ class BluetoothManager2 @Inject constructor(
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = try {
log(TAG) { "Nudging Android connection to $device" }
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
connectMethod.invoke(getBluetoothProfile().profile, device)
connectMethod.invoke(bluetoothProfile.proxy, device)
log(TAG) { "Nudged connection to $device" }
true
} catch (e: Exception) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
log(TAG) { "Nudged connection to $device" }
true
} catch (e: Exception) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
}.first()
companion object {
private val TAG = logTag("Bluetooth", "Manager2")
@@ -2,23 +2,15 @@ package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothProfile
import java.util.concurrent.atomic.AtomicBoolean
data class BluetoothProfile2(
private val profileType: Int,
internal val profileType: Int,
private val profileProxy: BluetoothProfile,
private val isConnectedAtomic: AtomicBoolean,
) {
val profile: BluetoothProfile
get() {
if (!isConnected) throw IllegalStateException("Proxy is not connected")
return profileProxy
}
val proxy: BluetoothProfile
get() = profileProxy
val connectedDevices: Set<BluetoothDevice>
get() = profile.connectedDevices.toSet()
val isConnected: Boolean
get() = isConnectedAtomic.get()
get() = proxy.connectedDevices.toSet()
}
@@ -1,20 +1,43 @@
package eu.darken.capod.main.core
import android.content.Context
import dagger.Reusable
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.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.Singleton
@Reusable
@Singleton
class PermissionTool @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
) {
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
suspend fun missingPermissions(): Set<Permission> = Permission.values()
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || generalSettings.monitorMode.value == MonitorMode.ALWAYS }
.filter { it.isRequired(context) }
.toSet()
fun recheck() {
log(TAG) { "recheck()" }
permissionCheckTrigger.value = UUID.randomUUID()
}
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.mods.DataBinderMod
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.NoPairedDeviceCardVH
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.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 BasicSingleApplePodsCardVH.Item }) { BasicSingleApplePodsCardVH(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 BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
}
override fun getItemCount(): Int = data.size
@@ -70,8 +70,10 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
}
}
vm.listItems.observe2(ui) {
adapter.update(it)
vm.listItems.observe2(ui) { adapter.update(it) }
vm.workerAutolaunch.observe2 {
// While UI is active, subscribe to the autolaunch routine
}
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.debug.autoreport.DebugSettings
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.navigation.navVia
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.MonitorMode
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.NoPairedDeviceCardVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.pods.*
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.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
@@ -57,9 +57,7 @@ class OverviewFragmentVM @Inject constructor(
}
}
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
private val requiredPermissions: Flow<Collection<Permission>> = permissionCheckTrigger
.map { permissionTool.missingPermissions() }
val workerAutolaunch: LiveData<Unit> = permissionTool.missingPermissions
.onEach {
if (it.isNotEmpty()) {
log(TAG) { "Missing permissions: $it" }
@@ -76,26 +74,24 @@ class OverviewFragmentVM @Inject constructor(
monitorControl.startMonitor()
}
}
.map { Unit }
.asLiveData2()
val requestPermissionEvent = SingleLiveEvent<Permission>()
private val pods: Flow<List<PodDevice>> = requiredPermissions
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
.flatMapLatest { permissions ->
if (permissions.isEmpty()) {
generalSettings.showAll.flow
.flatMapLatest { showAll ->
if (showAll) {
podMonitor.devices
} else {
podMonitor.mainDevice.map { mainDevice ->
mainDevice?.let { listOf(it) } ?: emptyList()
}
}
if (permissions.isNotEmpty()) {
return@flatMapLatest flowOf(emptyList())
}
generalSettings.showAll.flow.flatMapLatest { showAll ->
if (showAll) {
podMonitor.devices
} 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(
updateTicker,
requiredPermissions,
permissionTool.missingPermissions,
pods,
debugSettings.isDebugModeEnabled.flow,
generalSettings.showAll.flow,
) { tick, permissions, pods, isDebugMode, showAll ->
bluetoothManager.isBluetoothEnabled,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled ->
val items = mutableListOf<OverviewAdapter.Item>()
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
.map {
.map { perm ->
PermissionCardVH.Item(
permission = it,
permission = perm,
onRequest = { requestPermissionEvent.postValue(it) }
)
}
.run { items.addAll(this) }
if (!showAll && items.none { it is PodDeviceVH.Item } && permissions.isEmpty()) {
NoPairedDeviceCardVH.Item {
generalSettings.showAll.value = true
}.run { items.add(this) }
} else if (showAll && mainPod == null && permissions.isEmpty()) {
items.add(0, MissingMainDeviceVH.Item)
if (permissions.isEmpty()) {
if (!isBluetoothEnabled) {
items.add(0, BluetoothDisabledVH.Item)
} else if (mainPod == null) {
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
@@ -166,7 +166,7 @@ class OverviewFragmentVM @Inject constructor(
.asLiveData2()
fun onPermissionResult(granted: Boolean) {
if (granted) permissionCheckTrigger.value = UUID.randomUUID()
if (granted) permissionTool.recheck()
}
fun goToSettings() = launch {
@@ -4,29 +4,27 @@ import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
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
class NoPairedDeviceCardVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<NoPairedDeviceCardVH.Item, OverviewNopaireddeviceItemBinding>(
R.layout.overview_nopaireddevice_item,
class BluetoothDisabledVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<BluetoothDisabledVH.Item, OverviewBluetoothDisabledItemBinding>(
R.layout.overview_bluetooth_disabled_item,
parent
) {
override val viewBinding = lazy {
OverviewNopaireddeviceItemBinding.bind(itemView)
OverviewBluetoothDisabledItemBinding.bind(itemView)
}
override val onBindData: OverviewNopaireddeviceItemBinding.(
override val onBindData: OverviewBluetoothDisabledItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
showAllAction.setOnClickListener { item.onShowAll() }
}
data class Item(
val onShowAll: () -> Unit
) : OverviewAdapter.Item {
object Item : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
@@ -32,24 +32,25 @@ class PodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val cacheLock = Mutex()
val devices: Flow<List<PodDevice>> = generalSettings.scannerMode.flow
.flatMapLatest { scannerMode ->
bluetoothManager.isBluetoothEnabled.flatMapLatest { isBluetoothEnabled ->
if (isBluetoothEnabled) {
bleScanner.scan(scannerMode = scannerMode)
} else {
log(TAG, WARN) { "Bluetooth is current disabled" }
emptyFlow()
}
val devices: Flow<List<PodDevice>> = bluetoothManager.isBluetoothEnabled
.flatMapLatest { isBluetoothEnabled ->
if (isBluetoothEnabled) {
generalSettings.scannerMode.flow
} else {
log(TAG, WARN) { "Bluetooth is currently disabled" }
emptyFlow()
}
}
.flatMapLatest { bleScanner.scan(scannerMode = it) }
.map { result ->
// For each address we only want the newest result, upstream may batch data
result.groupBy { it.address }
.values
.map { sameAdrDevs ->
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
}
}
@@ -76,7 +76,7 @@ class MonitorWorker @AssistedInject constructor(
}
private suspend fun doDoWork() {
val permissionsMissingOnStart = permissionTool.missingPermissions()
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
return
@@ -99,7 +99,7 @@ class MonitorWorker @AssistedInject constructor(
generalSettings.monitorMode.flow
.flatMapLatest { monitorMode ->
val missingPermsFlow = permissionTool.missingPermissions()
val missingPermsFlow = permissionTool.missingPermissions.first()
if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing for $monitorMode: $missingPermsFlow" }
workerScope.coroutineContext.cancelChildren()
@@ -1,7 +1,6 @@
package eu.darken.capod.pods.core.apple
import android.content.Context
import android.util.Range
import androidx.annotation.StringRes
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.log
@@ -98,7 +97,7 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
val caseLidState: LidState
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.
* The upper limits are not the maximums but are only reached if playing with the case.
*/
enum class LidState(val rawRange: Range<UByte>) {
OPEN(0x30, 0x37),
CLOSED(0x38, 0x3F),
NOT_IN_CASE(0x00, 0x03),
UNKNOWN(0xFF, 0xFF);
constructor(vararg raw: Int) : this(Range(raw[0].toUByte(), (raw[1].toUByte())))
enum class LidState(val rawRange: IntRange) {
OPEN(0x30..0x37),
CLOSED(0x38..0x3F),
NOT_IN_CASE(0x00..0x03),
UNKNOWN(0xFF..0xFF);
}
val deviceColor: DeviceColor
@@ -1,6 +1,8 @@
package eu.darken.capod.reaction.autoconnect
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.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
@@ -22,29 +24,36 @@ class AutoConnect @Inject constructor(
) {
fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() }
.filterNotNull()
.distinctUntilChanged()
.map { podDevice ->
log(TAG) { "mainPodDevice is $podDevice" }
.flatMapLatest { isAutoConnectEnabled ->
if (isAutoConnectEnabled) {
combine(
bluetoothManager.connectedDevices().distinctUntilChanged(),
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
if (mainDeviceAddr.isNullOrEmpty()) {
log(TAG) { "mainDeviceAddress is null" }
log(TAG, WARN) { "mainDeviceAddress is null" }
return@map
} else {
log(TAG) { "mainDeviceAddress is $mainDeviceAddr" }
}
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr }
if (bondedDevice == null) {
log(TAG) { "No bonded device matches $mainDeviceAddr" }
log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" }
return@map
} 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
}
@@ -57,12 +66,12 @@ class AutoConnect @Inject constructor(
log(TAG) { "Checking condition $condition" }
val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (podDevice) {
is DualApplePods -> podDevice.caseLidState == DualApplePods.LidState.OPEN
AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true
}
AutoConnectCondition.IN_EAR -> when (podDevice) {
is HasEarDetection -> podDevice.isBeingWorn
AutoConnectCondition.IN_EAR -> when (mainDevice) {
is HasEarDetection -> mainDevice.isBeingWorn
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.reaction.popup.ui.PopUpNotification
import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,12 +23,18 @@ class PopUpReaction @Inject constructor(
) {
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()
.setupCommonEventHandlers(TAG) { "monitor" }
.map { (previous, current) ->
if (previous is DualApplePods? && current is DualApplePods) {
log(TAG) {
log(TAG, VERBOSE) {
val prev = previous?.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})"
@@ -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">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_label"
android:id="@+id/bluetooth_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:text="@string/overview_bluetooth_disabled_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/overview_nopaireddevices_label" />
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_description"
android:id="@+id/bluetooth_description"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="4dp"
android:layout_marginHorizontal="16dp"
android:layout_height="wrap_content"
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"
android:layout_marginBottom="16dp"
android:text="@string/overview_bluetooth_disabled_description"
app:layout_constraintBottom_toBottomOf="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>
</com.google.android.material.card.MaterialCardView>
+5 -3
View File
@@ -71,9 +71,7 @@
<string name="pods_microphone_label">Microphone</string>
<string name="pods_yours">Yours</string>
<string name="overview_nopaireddevices_label">No paired device connected.</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_label">No primary 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="settings_label">Settings</string>
@@ -146,4 +144,8 @@
<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="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>
+20 -10
View File
@@ -21,6 +21,15 @@
<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
android:icon="@drawable/ic_changelog_onsurface"
android:key="core.changelog"
@@ -28,7 +37,17 @@
app:summary="v?.?.?">
<intent
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
@@ -37,14 +56,5 @@
app:summary="@string/general_thank_you_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>
</PreferenceScreen>
+1 -1
View File
@@ -8,7 +8,7 @@
android:title="@string/issue_tracker_label">
<intent
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
android:icon="@drawable/ic_discord_onsurface"
@@ -188,7 +188,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
// 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") {
// 39 0011 1001
caseLidState shouldBe DualApplePods.LidState.UNKNOWN
caseLidState shouldBe DualApplePods.LidState.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") {
+1 -1
View File
@@ -7,7 +7,7 @@ buildscript {
'version' : [
'major': 1,
'minor': 0,
'minor': 1,
'patch': 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