Compare commits

..
17 Commits
30 changed files with 405 additions and 181 deletions
+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
+19 -12
View File
@@ -1,11 +1,21 @@
<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:
<img src="https://github.com/d4rken/capod/raw/master/.assets/banner.png" width="400">
* 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:
@@ -20,17 +30,17 @@ Currently 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)
* Help translate CAPod [on Crowdin](https://crowdin.com/project/airpod-companion)
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
## 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)
@@ -38,10 +48,7 @@ Currently supported models:
## Screenshots
<img src="https://github.com/d4rken/capod/raw/master/.assets/screenshots/1.png" width="200">
<img src="https://github.com/d4rken/capod/raw/master/.assets/screenshots/2.png" width="200">
<img src="https://github.com/d4rken/capod/raw/master/.assets/screenshots/3.png" width="200">
<img src="https://github.com/d4rken/capod/raw/master/.assets/screenshots/4.png" width="200">
<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
@@ -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()
}
@@ -108,37 +108,6 @@ class OverviewFragmentVM @Inject constructor(
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 { perm ->
@@ -157,6 +126,40 @@ class OverviewFragmentVM @Inject constructor(
}
}
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
}
.catch { errorEvents.postValue(it) }
@@ -48,7 +48,9 @@ class PodMonitor @Inject constructor(
.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
}
}
@@ -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>
+2
View File
@@ -146,4 +146,6 @@
<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") {
+2 -2
View File
@@ -7,8 +7,8 @@ buildscript {
'version' : [
'major': 1,
'minor': 0,
'patch': 1,
'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