Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eb97cc966 | ||
|
|
e4f405ec65 | ||
|
|
f392df05f9 | ||
|
|
0f8e441a92 | ||
|
|
edbc944724 | ||
|
|
65eb92dcb1 | ||
|
|
7df24f9c76 | ||
|
|
5d72d0116b | ||
|
|
91a16f55d9 | ||
|
|
9ae3516a83 | ||
|
|
f3c20caa13 | ||
|
|
680bcbad2a | ||
|
|
5f60f2b5eb | ||
|
|
7efe6619d2 | ||
|
|
1a3c2b3d23 | ||
|
|
7cccff8a71 | ||
|
|
4a3e5056b6 | ||
|
|
16a880a92d | ||
|
|
79db1e2412 | ||
|
|
b6e8d69eff | ||
|
|
76c1b65b89 | ||
|
|
d7902bc86e |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 761 KiB |
|
After Width: | Height: | Size: 605 KiB |
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||

|

|
||||||
[](https://github.com/d4rken/capod/releases/latest)
|
[](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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ buildscript {
|
|||||||
|
|
||||||
'version' : [
|
'version' : [
|
||||||
'major': 1,
|
'major': 1,
|
||||||
'minor': 0,
|
'minor': 1,
|
||||||
'patch': 0,
|
'patch': 0,
|
||||||
'build': 0,
|
'build': 0,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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!
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 938 KiB |
|
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
|
||||||
@@ -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
|
||||||