Compare commits

...
23 Commits
Author SHA1 Message Date
darken afe1399f72 Release: 2.9.0-rc0 2023-04-02 22:25:17 +02:00
Matthias Urhahn 20ce2d3e22 A troubleshooter that automatically goes through different compatibility settings. (#125) 2023-04-02 22:22:15 +02:00
Matthias Urhahn cea8f2290d More IAP improvements (#121)
* More IAP improvements

* More refactoring

* More tolerance towards GPlay API issues.

* Bump billing dependency

* Migrate away from deprecated methods.
2023-03-20 08:21:54 +01:00
darken 1b5b59c41b Release: 2.8.4-rc0 2023-03-13 17:28:28 +01:00
Matthias Urhahn 8df7c19e84 Fix billing client disconnection handling (#117) 2023-03-13 17:28:01 +01:00
darken 88b0069b94 Release: 2.8.3-rc0 2023-03-13 13:48:11 +01:00
Matthias Urhahn 3b8ddc9f06 Fix reversed AirPods Max wear-detection (#116)
* Fix reversed AirPods Max wear-detection
Fixes #115

* Remove unnecessary test
2023-03-13 13:47:32 +01:00
darken 708c7f38ec Merge remote-tracking branch 'origin/main' into main 2023-03-12 14:05:25 +01:00
darken 139daa1cc2 Update full gplay text 2023-03-12 14:05:20 +01:00
Matthias Urhahn b85ac778c0 Address that gplay api can emit multiple times (#114) 2023-03-12 02:58:37 +01:00
Matthias Urhahn a3e2c9e61d Bump dependencies and gradle (#113) 2023-03-11 18:16:24 +01:00
Matthias Urhahn ad78a0570c For some reason we can finish the widget update broadcast twice. (#112)
I can't yet determine why this race-condition occurs, due to the forced timeout, it shouldn't be due to exceeding the receiver ANR window...
2023-03-11 13:01:15 +01:00
Matthias Urhahn 7e6ee274d8 Quick data updates can cause concurrent access and modifying of the notification builder. (#111)
Internally building the notification can then throw a ConcurrentModificationException.
2023-03-11 13:01:05 +01:00
Matthias Urhahn daf7411c36 Show worker foreground notification early. (#110) 2023-03-11 13:00:55 +01:00
darken d3dedbe6cd Release: 2.8.2-rc0 2023-03-10 12:13:30 +01:00
Matthias Urhahn d5411586d5 Update translations (#109)
* Update translations, add Portuguese

* Update translations
2023-03-10 12:12:31 +01:00
Matthias Urhahn 94e2d40554 Fix AirPods Max wear detection. (#108)
Closes #106
2023-03-10 11:30:40 +01:00
Matthias Urhahn db80fa1b46 Wear: Remove unused code (#104)
* Wear: Remove unused code

* Remove more code
2023-03-02 16:39:11 +01:00
Matthias Urhahn 9b569b8d07 Update Turkish translation (#100) 2023-02-20 09:39:27 +01:00
darken 90288880fb Add f-droid as source 2023-02-20 09:14:20 +01:00
darken 870e518ff6 Release: 2.8.1-rc0 2023-02-09 22:01:02 +01:00
darken 6d89169bae Update release.sh script to create default changelogs for f-droid 2023-02-09 21:59:44 +01:00
darken ac77ee2703 Update default changelog for GPlay 2023-02-09 21:53:33 +01:00
66 changed files with 991 additions and 312 deletions
+1
View File
@@ -12,3 +12,4 @@
*.jks
.local/*
/fastlane/report.xml
/fastlane/Appfile
+6 -5
View File
@@ -43,12 +43,13 @@ Currently supported models:
## Download
| Source | Status |
|------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Source | Status |
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [![](https://img.shields.io/f-droid/v/eu.darken.capod)](https://f-droid.org/en/packages/eu.darken.capod/) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
## Support the project
+1 -1
View File
@@ -1 +1 @@
2.8.0-rc0 20800000
2.9.0-rc0 20900000
@@ -0,0 +1,10 @@
import android.content.BroadcastReceiver
import eu.darken.capod.common.debug.logging.log
fun BroadcastReceiver.PendingResult.finish2(): Boolean = try {
finish()
true
} catch (e: IllegalStateException) {
log { "BroadcastReceiver.PendingResult.finish() failed: $e" }
false
}
@@ -32,9 +32,9 @@ class BleScanner @Inject constructor(
@SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>,
scannerMode: ScannerMode = ScannerMode.BALANCED,
disableOffloadFiltering: Boolean = true,
disableOffloadBatching: Boolean = true,
disableDirectScanCallback: Boolean = true,
disableOffloadFiltering: Boolean = false,
disableOffloadBatching: Boolean = false,
disableDirectScanCallback: Boolean = false,
): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
@@ -1,18 +0,0 @@
package eu.darken.capod.monitor.core
import dagger.BindsInstance
import dagger.hilt.DefineComponent
import dagger.hilt.components.SingletonComponent
@MonitorScope
@DefineComponent(parent = SingletonComponent::class)
interface MonitorComponent {
@DefineComponent.Builder
interface Builder {
fun coroutineScope(@BindsInstance coroutineScope: MonitorCoroutineScope): Builder
fun build(): MonitorComponent
}
}
@@ -1,16 +0,0 @@
package eu.darken.capod.monitor.core
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import kotlinx.coroutines.CoroutineScope
@InstallIn(MonitorComponent::class)
@Module()
abstract class ProcessorModule {
@Binds
@MonitorScope
abstract fun processorScope(scope: MonitorCoroutineScope): CoroutineScope
}
@@ -1,8 +0,0 @@
package eu.darken.capod.monitor.core
import javax.inject.Qualifier
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class MonitorScope
@@ -14,6 +14,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
@@ -113,6 +114,7 @@ class PodMonitor @Inject constructor(
disableDirectCallback = useIndirectScanResultCallback,
)
}
.throttleLatest(1000)
.flatMapLatest { options ->
val filters = when {
options.showUnfiltered -> {
@@ -3,6 +3,7 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
@@ -22,21 +23,18 @@ data class AirPodsMax(
override val proximityMessage: ProximityPairing.Message,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection, HasAppleColor {
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
override val rssi: Int
get() = rssiAverage ?: super<SingleApplePods>.rssi
val isHeadphonesBeingWorn: Boolean
get() = rawStatus.isBitSet(1)
val isHeadsetBeingCharged: Boolean
override val isHeadsetBeingCharged: Boolean
get() = rawFlags.isBitSet(0)
override val isBeingWorn: Boolean
get() = isHeadphonesBeingWorn
get() = rawStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
+60 -1
View File
@@ -1,2 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"></resources>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string>
<string name="overview_nomaindevice_label">Sem dispositivo primário</string>
<string name="overview_nomaindevice_description">É improvável que os dispositivos detetados sejam seus. Ligue e conecte o seu dispositivo ou ajuste as definições.</string>
<string name="overview_bluetooth_disabled_label">O Bluetooth está desligado</string>
<string name="overview_bluetooth_disabled_description">O Bluetooth está desligado, ativa-o ;)</string>
<string name="permission_bluetooth_connect_label">Conexão ao Bluetooth</string>
<string name="permission_bluetooth_connect_description">Esta aplicação requer permissão para conectar com o Bluetooth para interagir com dispositivos emparelhados e iniciar conexões.</string>
<string name="permission_bluetooth_scan_label">Explorar Bluetooth</string>
<string name="permission_bluetooth_scan_description">A permissão para explorar o Bluetooth autoriza esta aplicação para descobrir e detetar dados de Bluetooth de dispositivos por perto como os seus AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Esta aplicação requer a permissão do Bluetooth para conectar e emparelhar a dispositivos bluetooth.</string>
<string name="permission_access_fine_location_label">Aceder a localização exata</string>
<string name="permission_access_fine_location_description">CAPod utiliza a localização exata para receber dados de \'Bluetooth Low Energy\'. Os seus headphones utilizam tecnologia \'Bluetooth Low Energy\' para transmitir o seu estado. Esta aplicação NÃO irá utilizar dados de Bluetooth para determinar a sua localização.</string>
<string name="permission_background_location_label">Acesso à localização de fundo</string>
<string name="permission_background_location_description">CAPod utiliza o acesso à localização de fundo para habilitar funcionalidades como \"Mostrar pop-up\" e \"AutoConectar\" enquanto a aplicação está fechada. Acesso à localização de fundo autoriza à aplicação receber dados de \'Bluetooth Low Energy\' enquanto está de fundo. Esta aplicação NÃO irá utilizar dados de Bluetooth para determinar a sua localização.</string>
<string name="permission_ignore_battery_optimizations_label">Desativar otimizações de bateria</string>
<string name="permission_ignore_battery_optimizations_description">Otimizações de bateria impedem que esta aplicação receba dados de Bluetooth fidedignos enquanto a aplicação está de fundo.</string>
<string name="permission_required_title">A seguinte permissão é necessária:</string>
<string name="permission_system_alert_window_label">Janela de Alerta do Sistema</string>
<string name="permission_system_alert_window_description">Autorizar CAPod para aparecer por cima de outras aplicações para tornar possível a funcionalidade \"Mostrar pop-up\".</string>
<string name="settings_scanner_mode_lowpower_label">Poupar bateria</string>
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
<string name="settings_scanner_mode_lowlatency_label">Baixa latência</string>
<string name="settings_monitor_mode_manual_label">Quando a aplicação está aberta</string>
<string name="settings_monitor_mode_automatic_label">Quando o dispositivo é conectado</string>
<string name="settings_monitor_mode_always_label">Sempre</string>
<string name="settings_reaction_autoconnect_whenseen_label">Quando visto</string>
<string name="settings_reaction_autoconnect_caseopen_label">Caixa está aberta</string>
<string name="settings_reaction_autoconnect_inear_label">No ouvido</string>
<string name="pods_dual_left_label">Pod esquerdo</string>
<string name="pods_dual_right_label">Pod direito</string>
<string name="pods_case_label">Caixa</string>
<string name="pods_case_status_open_label">Aberta</string>
<string name="pods_case_status_closed_label">Fechada</string>
<string name="pods_connection_state_disconnected_label">Não está conectado a um dispositivo</string>
<string name="pods_connection_state_idle_label">Conectado a um dispositivo, mas inativo</string>
<string name="pods_connection_state_music_label">Em modo de música</string>
<string name="pods_connection_state_call_label">Em modo de chamada</string>
<string name="pods_connection_state_ringing_label">A tocar</string>
<string name="pods_connection_state_hanging_up_label">A terminar a chamada</string>
<string name="pods_connection_state_unknown_label">Estado de conexão desconhecido</string>
<string name="pods_unknown_raw_data_label">Dados não tratados</string>
<string name="pods_unknown_label">Dispositivo desconhecido</string>
<string name="pods_unknown_contact_dev">Este é um dispositivo desconhecido, mas usa um formato de mensagem semelhante. Vamos adicionar suporte para isso, entre em contacto comigo :)</string>
<string name="pods_none_label_short">Sem dispositivo</string>
<string name="pods_charging_label">A carregar</string>
<string name="pods_inear_label">No ouvido</string>
<string name="pods_microphone_label">Microfone</string>
<string name="pods_yours">Seu</string>
<string name="headset_being_worn_label">A ser usado</string>
<string name="pods_case_unknown_state">Estado desconhecido</string>
<string name="last_seen_x">Última vez visto: %s</string>
<string name="first_seen_x">Primeira vez visto: %s</string>
</resources>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Yok</string>
<string name="general_error_label">Hata</string>
<string name="general_grant_permission_action">İzin ver</string>
@@ -11,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Bu uygulama, eşleştirilmiş cihazlarla etkileşim kurmak ve bağlantıları başlatmak için \"Bluetooth bağlantısı\" izni gerektirir.</string>
<string name="permission_bluetooth_scan_label">Bluetooth taraması</string>
<string name="permission_bluetooth_scan_description">\"Bluetooth tarama\" izni, bu uygulamanın AirPods\'unuz gibi yakındaki cihazlardan Bluetooth verilerini keşfetmesine ve almasına izin verir.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Bu uygulama, eşleştirilmiş bluetooth cihazlarına bağlanmak için \"Bluetooth\" izni gerektirir.</string>
<string name="permission_access_fine_location_label">Hassas konuma erişin</string>
<string name="permission_access_fine_location_description">CAPod, Bluetooth Düşük Enerji verilerini almak için \"iyi konum\" iznini kullanır. Kulaklıklarınız durumlarını yayınlamak için Bluetooth Düşük Enerji teknolojisini kullanır. Bu uygulama konumunuzu belirlemek için Bluetooth verilerini KULLANMAYACAKTIR.</string>
@@ -1,6 +1,5 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -21,15 +20,4 @@ class SingleApplePodsTest : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte()
}
}
@Test
fun `test values based on AirPodMax`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
}
}
}
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
@@ -27,8 +28,6 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
model shouldBe PodDevice.Model.AIRPODS_MAX
}
}
@@ -50,8 +49,6 @@ class AirPodsMaxTest : BaseAirPodsTest() {
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
}
}
@@ -72,9 +69,25 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
rawStatus.isBitSet(5) shouldBe true
podStyle shouldBe HasAppleColor.DeviceColor.BLUE
}
}
@Test
fun `wear status`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 03 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
rawStatus shouldBe 0x03.toUByte()
rawStatus.isBitSet(5) shouldBe false
isBeingWorn shouldBe false
}
create<AirPodsMax>("07 19 01 0A 20 23 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
rawStatus shouldBe 0x23.toUByte()
rawStatus.isBitSet(5) shouldBe true
isBeingWorn shouldBe true
}
}
}
@@ -6,8 +6,6 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoints
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -15,9 +13,7 @@ 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
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.PodMonitor
import kotlinx.coroutines.*
@@ -31,22 +27,12 @@ import kotlinx.coroutines.flow.take
class MonitorWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val params: WorkerParameters,
monitorComponentBuilder: MonitorComponent.Builder,
private val dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
private val permissionTool: PermissionTool,
private val podMonitor: PodMonitor,
private val bluetoothManager: BluetoothManager2,
) : CoroutineWorker(context, params) {
private val workerScope = MonitorCoroutineScope()
private val monitorComponent = monitorComponentBuilder
.coroutineScope(workerScope)
.build()
private val entryPoint by lazy {
EntryPoints.get(monitorComponent, MonitorWorkerEntryPoint::class.java)
}
private var finishedWithError = false
@@ -1,9 +0,0 @@
package eu.darken.capod.wear.core
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import eu.darken.capod.monitor.core.MonitorComponent
@InstallIn(MonitorComponent::class)
@EntryPoint
interface MonitorWorkerEntryPoint
@@ -13,13 +13,9 @@ import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.wear.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.wear.ui.overview.cards.MissingMainDeviceVH
@@ -38,11 +34,8 @@ class OverviewFragmentVM @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val podMonitor: PodMonitor,
private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val bluetoothManager: BluetoothManager2,
private val podDeviceCache: PodDeviceCache,
private val podFactory: PodFactory
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
private val updateTicker = channelFlow<Unit> {
@@ -54,30 +47,14 @@ class OverviewFragmentVM @Inject constructor(
val requestPermissionEvent = SingleLiveEvent<Permission>()
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
.flatMapLatest { permissions ->
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()
}
}
}
}
.catch { errorEvents.postValue(it) }
private val mainDevice = podMonitor.mainDevice.throttleLatest(1000)
val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
updateTicker,
permissionTool.missingPermissions,
debugSettings.isDebugModeEnabled.flow,
bluetoothManager.isBluetoothEnabled,
podMonitor.mainDevice.throttleLatest(1000),
mainDevice,
) { _, permissions, isDebugMode, isBluetoothEnabled, _ ->
val items = mutableListOf<OverviewAdapter.Item>()
+2 -1
View File
@@ -146,7 +146,8 @@ dependencies {
addTesting()
"gplayImplementation"("com.android.billingclient:billing:4.0.0")
"gplayImplementation"("com.android.billingclient:billing:5.1.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:5.1.0")
"gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2")
"gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3")
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="foss_upgrade_donate_label">Doar</string>
<string name="foss_upgrade_alreadydonated_label">Já doei</string>
<string name="foss_upgrade_no_money_label">Gastei todo o meu dinheiro em AirPods</string>
</resources>
@@ -1,13 +0,0 @@
package eu.darken.capod.common.upgrade.core
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.core.data.PurchasedSku
import eu.darken.capod.common.upgrade.core.data.Sku
fun Purchase.toPurchasedSku(): Collection<PurchasedSku> = skus.map {
PurchasedSku(Sku(it), this)
}
private val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Extensions")
@@ -49,7 +49,7 @@ class UpgradeRepoGplay @Inject constructor(
lastProStateAt = now
Info(billingData = data)
}
(now - lastProStateAt) < 6 * 60 * 1000L -> { // 6 hours
(now - lastProStateAt) < 6 * 60 * 60 * 1000L -> { // 6 hours
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null)
}
@@ -62,7 +62,7 @@ class UpgradeRepoGplay @Inject constructor(
// Ignore Google Play errors if the last pro state was recent
val now = System.currentTimeMillis()
log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, error=$it" }
if ((now - lastProStateAt) < 6 * 60 * 60 * 1000L) { // 6 hours
if ((now - lastProStateAt) < 24 * 60 * 60 * 1000L) { // 24 hours
log(TAG, VERBOSE) { "We are not pro, but were recently, and just and an error, what is GPlay doing???" }
emit(Info(gracePeriod = true, billingData = null))
} else {
@@ -123,7 +123,7 @@ class UpgradeRepoGplay @Inject constructor(
get() = UpgradeRepo.Type.GPLAY
override val isPro: Boolean
get() = billingData?.getProSku() != null
get() = billingData?.getProSku() != null || gracePeriod
override val upgradedAt: Instant?
get() = billingData
@@ -2,6 +2,7 @@ package eu.darken.capod.common.upgrade.core.client
import android.app.Activity
import com.android.billingclient.api.*
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
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
@@ -38,7 +39,7 @@ data class BillingClientConnection(
if (!result.isSuccess) {
log(TAG, WARN) { "queryPurchases() failed" }
throw BillingClientException(result)
throw BillingResultException(result)
} else {
requireNotNull(purchases)
}
@@ -47,34 +48,31 @@ data class BillingClientConnection(
return purchases
}
suspend fun acknowledgePurchase(purchase: Purchase): BillingResult {
suspend fun acknowledgePurchase(purchase: Purchase) {
val ack = AcknowledgePurchaseParams.newBuilder().apply {
setPurchaseToken(purchase.purchaseToken)
}.build()
val ackResult = suspendCoroutine<BillingResult> { continuation ->
val result = suspendCoroutine<BillingResult> { continuation ->
client.acknowledgePurchase(ack) { continuation.resume(it) }
}
log(TAG) {
"acknowledgePurchase(purchase=$purchase): code=${ackResult.responseCode}, message=${ackResult.debugMessage})"
}
if (!ackResult.isSuccess) {
throw BillingClientException(ackResult)
}
log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" }
return ackResult
if (!result.isSuccess) throw BillingResultException(result)
}
suspend fun querySku(sku: Sku): Sku.Details {
val skuParams = SkuDetailsParams.newBuilder().apply {
setType(BillingClient.SkuType.INAPP)
setSkusList(listOf(sku.id))
val productDetails = QueryProductDetailsParams.Product.newBuilder().apply {
setProductType(BillingClient.ProductType.INAPP)
setProductId(sku.id)
}.build()
val (result, details) = suspendCoroutine<Pair<BillingResult, Collection<SkuDetails>?>> { continuation ->
client.querySkuDetailsAsync(skuParams) { skuResult, skuDetails ->
continuation.resume(skuResult to skuDetails)
val params = QueryProductDetailsParams.newBuilder().setProductList(listOf(productDetails)).build()
val (result, details) = suspendCoroutine<Pair<BillingResult, Collection<ProductDetails>?>> { continuation ->
client.queryProductDetailsAsync(params) { result, skuDetails ->
continuation.resume(result to skuDetails)
}
}
@@ -82,7 +80,7 @@ data class BillingClientConnection(
"querySku(sku=$sku): code=${result.responseCode}, debug=${result.debugMessage}), skuDetails=$details"
}
if (!result.isSuccess) throw BillingClientException(result)
if (!result.isSuccess) throw BillingResultException(result)
if (details.isNullOrEmpty()) throw IllegalStateException("Unknown SKU, no details available.")
@@ -97,10 +95,16 @@ data class BillingClientConnection(
suspend fun launchBillingFlow(activity: Activity, skuDetails: Sku.Details): BillingResult {
log(TAG) { "launchBillingFlow(activity=$activity, skuDetails=$skuDetails)" }
return client.launchBillingFlow(
activity,
BillingFlowParams.newBuilder().setSkuDetails(skuDetails.details.single()).build()
)
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().apply {
setProductDetails(skuDetails.details.first())
}.build()
val billingFlowParams = BillingFlowParams.newBuilder().apply {
setProductDetailsParamsList(listOf(productParams))
}.build()
return client.launchBillingFlow(activity, billingFlowParams)
}
companion object {
@@ -14,15 +14,15 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
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.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class BillingClientConnectionProvider @Inject constructor(
@@ -48,36 +48,40 @@ class BillingClientConnectionProvider @Inject constructor(
}
}.build()
val connectionResult = suspendCoroutine<BillingResult> { continuation ->
log(TAG, VERBOSE) { "startConnection(...)" }
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
log(TAG, VERBOSE) {
"onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})"
log(TAG, VERBOSE) { "startConnection(...)" }
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
log(TAG, VERBOSE) {
"onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})"
}
when (result.responseCode) {
BillingResponseCode.OK -> {
val connection = BillingClientConnection(client, purchasePublisher)
trySendBlocking(connection)
launch {
try {
purchasePublisher.value = connection.queryPurchases()
log(TAG) { "Initial IAP query successful." }
} catch (e: Exception) {
log(TAG, ERROR) { "Initial IAP query failed:\n${e.asLog()}" }
}
}
}
else -> {
close(BillingResultException(result))
}
continuation.resume(result)
}
}
override fun onBillingServiceDisconnected() {
log(TAG, VERBOSE) { "onBillingServiceDisconnected() " }
close(CancellationException("Billing service disconnected"))
}
})
}
val billingClientConnection = when (connectionResult.responseCode) {
BillingResponseCode.OK -> BillingClientConnection(client, purchasePublisher)
else -> throw BillingClientException(connectionResult)
}
try {
purchasePublisher.value = billingClientConnection.queryPurchases()
log(TAG) { "Initial IAP query successful." }
} catch (e: Exception) {
log(TAG, ERROR) { "Initial IAP query failed:\n${e.asLog()}" }
}
send(billingClientConnection)
override fun onBillingServiceDisconnected() {
log(TAG, VERBOSE) { "onBillingServiceDisconnected() " }
close(BillingException("Billing service disconnected"))
}
})
log(TAG) { "Awaiting close." }
awaitClose {
@@ -89,26 +93,28 @@ class BillingClientConnectionProvider @Inject constructor(
val connection: Flow<BillingClientConnection> = connectionProvider
.setupCommonEventHandlers(TAG) { "connection" }
.retryWhen { cause, attempt ->
log(TAG) { "Billing client connection error: ${cause.asLog()}" }
if (cause is CancellationException) {
log(TAG) { "BillingClient connection cancelled." }
return@retryWhen false
}
if (attempt > 5) {
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
if (cause !is BillingException) {
log(TAG, WARN) { "Unknown exception type: $cause" }
return@retryWhen false
}
if (cause !is BillingClientException) {
log(TAG, WARN) { "Unknown BillingClient exception type: $cause" }
return@retryWhen false
} else {
log(TAG) { "BillingClient exception: $cause; ${cause.result}" }
}
if (cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) {
if (cause is BillingResultException && cause.result.isGplayUnavailablePermanent) {
log(TAG) { "Got BILLING_UNAVAILABLE while trying to connect client." }
return@retryWhen false
}
if (attempt > 5) {
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
return@retryWhen false
}
log(TAG) { "Will retry BillingClient connection... *sigh*" }
delay(3000 * attempt)
true
@@ -1,11 +0,0 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.BillingResult
class BillingClientException(val result: BillingResult) : Exception() {
override val message: String?
get() = result.debugMessage
override fun toString(): String =
"BillingClientException(code=${result.responseCode}, message=${result.debugMessage})"
}
@@ -4,4 +4,14 @@ import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
internal val BillingResult.isSuccess: Boolean
get() = responseCode == BillingClient.BillingResponseCode.OK
get() = responseCode == BillingClient.BillingResponseCode.OK
internal val BillingResult.isGplayUnavailableTemporary: Boolean
get() = setOf(
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
BillingClient.BillingResponseCode.SERVICE_TIMEOUT
).contains(responseCode)
internal val BillingResult.isGplayUnavailablePermanent: Boolean
get() = responseCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE
@@ -0,0 +1,3 @@
package eu.darken.capod.common.upgrade.core.client
open class BillingException(override val message: String) : Exception()
@@ -0,0 +1,9 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.BillingResult
class BillingResultException(val result: BillingResult) : BillingException(result.debugMessage) {
override fun toString(): String =
"BillingResultException(code=${result.responseCode}, message=${result.debugMessage})"
}
@@ -7,13 +7,9 @@ import eu.darken.capod.common.error.LocalizedError
class GplayServiceUnavailableException(cause: Throwable) : Exception("Google Play services are unavailable.", cause),
HasLocalizedError {
override fun getLocalizedError(context: Context): LocalizedError {
return LocalizedError(
throwable = this,
label = "Google Play Services Unavailable",
description = context.getString(R.string.upgrades_gplay_unavailable_error)
)
}
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
label = "Google Play Services Unavailable",
description = context.getString(R.string.upgrades_gplay_unavailable_error)
)
}
@@ -1,11 +1,14 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.toPurchasedSku
data class BillingData(
val purchases: Collection<Purchase>
) {
val purchasedSkus: Collection<PurchasedSku>
get() = purchases.map { it.toPurchasedSku() }.flatten()
private fun Purchase.toPurchasedSku(): Collection<PurchasedSku> = skus.map {
PurchasedSku(Sku(it), this)
}
}
@@ -1,7 +1,6 @@
package eu.darken.capod.common.upgrade.core.data
import android.app.Activity
import com.android.billingclient.api.BillingClient.BillingResponseCode
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
@@ -10,9 +9,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider
import eu.darken.capod.common.upgrade.core.client.BillingClientException
import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.client.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@@ -53,37 +50,34 @@ class BillingDataRepo @Inject constructor(
}
.forEach {
log(TAG, INFO) { "Acknowledging purchase: $it" }
try {
client.acknowledgePurchase(it)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to ancknowledge purchase: $it\n${e.asLog()}" }
}
client.acknowledgePurchase(it)
}
}
.setupCommonEventHandlers(TAG) { "connection-acks" }
.retryWhen { cause, attempt ->
log(TAG, ERROR) { "Failed to acknowledge purchase: ${cause.asLog()}" }
if (cause is CancellationException) {
log(TAG) { "Ack was cancelled (appScope?) cancelled." }
return@retryWhen false
}
if (attempt > 5) {
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
return@retryWhen false
}
if (cause !is BillingClientException) {
log(TAG, WARN) { "Unknown BillingClient exception type: $cause" }
if (cause !is BillingException) {
log(TAG, WARN) { "Unknown exception type: $cause" }
return@retryWhen false
} else {
log(TAG) { "BillingClient exception: $cause; ${cause.result}" }
}
if (cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) {
if (cause is BillingResultException && cause.result.isGplayUnavailablePermanent) {
log(TAG) { "Got BILLING_UNAVAILABLE while trying to ACK purchase." }
return@retryWhen false
}
log(TAG) { "Will retry ACK" }
log(TAG) { "Will retry ACK (attempt=$attempt)" }
delay(3000 * attempt)
true
}
@@ -108,7 +102,7 @@ class BillingDataRepo @Inject constructor(
} catch (e: Exception) {
log(TAG, WARN) { "Failed to start IAP flow:\n${e.asLog()}" }
val ignoredCodes = listOf(3, 6)
if (e !is BillingClientException || !e.result.responseCode.let { ignoredCodes.contains(it) }) {
if (e !is BillingResultException || !e.result.responseCode.let { ignoredCodes.contains(it) }) {
Bugs.report(TAG, "IAP flow failed for $sku", e)
}
@@ -119,16 +113,14 @@ class BillingDataRepo @Inject constructor(
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo")
internal fun Throwable.tryMapUserFriendly(): Throwable {
if (this !is BillingClientException) return this
return when (result.responseCode) {
BillingResponseCode.BILLING_UNAVAILABLE,
BillingResponseCode.SERVICE_UNAVAILABLE,
BillingResponseCode.SERVICE_DISCONNECTED,
BillingResponseCode.SERVICE_TIMEOUT -> GplayServiceUnavailableException(this)
else -> this
internal fun Throwable.tryMapUserFriendly(): Throwable = when {
this is BillingResultException && this.result.isGplayUnavailableTemporary -> {
GplayServiceUnavailableException(this)
}
this is BillingResultException && this.result.isGplayUnavailablePermanent -> {
GplayServiceUnavailableException(this)
}
else -> this
}
}
}
@@ -1,12 +1,12 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.SkuDetails
import com.android.billingclient.api.ProductDetails
data class Sku(
val id: String
) {
data class Details(
val sku: Sku,
val details: Collection<SkuDetails>,
val details: Collection<ProductDetails>,
)
}
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="upgrades_gplay_unavailable_error">Os serviços do Google Play estão indisponíveis.</string>
<string name="upgrades_no_purchases_found_check_account">Nenhuma compra encontrada. Está a usar a conta correta?</string>
</resources>
@@ -123,7 +123,9 @@ class OverviewFragmentVM @Inject constructor(
if (!isBluetoothEnabled) {
items.add(0, BluetoothDisabledVH.Item)
} else if (mainPod == null) {
items.add(0, MissingMainDeviceVH.Item)
items.add(0, MissingMainDeviceVH.Item {
OverviewFragmentDirections.actionOverviewFragmentToTroubleShooterFragment().navVia(this)
})
}
}
@@ -21,10 +21,12 @@ class MissingMainDeviceVH(parent: ViewGroup) :
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
troubleshootAction.setOnClickListener { item.onTroubleShoot() }
}
object Item : OverviewAdapter.Item {
data class Item(
val onTroubleShoot: () -> Unit,
) : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
@@ -22,6 +22,7 @@ import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.*
import finish2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@@ -39,7 +40,7 @@ class WidgetProvider : AppWidgetProvider() {
private fun executeAsync(
tag: String,
timeout: Duration = Duration.ofSeconds(8),
timeout: Duration = Duration.ofSeconds(7),
block: suspend () -> Unit
) {
val start = System.currentTimeMillis()
@@ -52,7 +53,7 @@ class WidgetProvider : AppWidgetProvider() {
} catch (e: Exception) {
log(TAG, ERROR) { "executeAsync($tag) failed: ${e.asLog()}" }
} finally {
asyncBarrier.finish()
asyncBarrier.finish2()
val stop = System.currentTimeMillis()
log(TAG, VERBOSE) { "executeAsync($tag) DONE (${stop - start}ms) " }
}
@@ -5,10 +5,10 @@ import android.bluetooth.BluetoothDevice
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoints
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
@@ -23,7 +23,6 @@ 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.widget.WidgetManager
import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.ui.MonitorNotifications
@@ -39,7 +38,6 @@ import kotlinx.coroutines.flow.*
class MonitorWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val params: WorkerParameters,
monitorComponentBuilder: MonitorComponent.Builder,
private val dispatcherProvider: DispatcherProvider,
private val monitorNotifications: MonitorNotifications,
private val notificationManager: NotificationManager,
@@ -55,13 +53,6 @@ class MonitorWorker @AssistedInject constructor(
) : CoroutineWorker(context, params) {
private val workerScope = MonitorCoroutineScope()
private val monitorComponent = monitorComponentBuilder
.coroutineScope(workerScope)
.build()
private val entryPoint by lazy {
EntryPoints.get(monitorComponent, MonitorWorkerEntryPoint::class.java)
}
private var finishedWithError = false
@@ -69,10 +60,16 @@ class MonitorWorker @AssistedInject constructor(
log(TAG, VERBOSE) { "init(): workerId=$id" }
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return monitorNotifications.getForegroundInfo(null)
}
override suspend fun doWork(): Result = try {
val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
setForeground(monitorNotifications.getForegroundInfo(null))
doDoWork()
val duration = System.currentTimeMillis() - start
@@ -101,7 +98,6 @@ class MonitorWorker @AssistedInject constructor(
val monitorJob = podMonitor.mainDevice
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.onStart { setForeground(monitorNotifications.getForegroundInfo(null)) }
.distinctUntilChanged()
.throttleLatest(1000)
.onEach { currentDevice ->
@@ -1,9 +0,0 @@
package eu.darken.capod.monitor.core.worker
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import eu.darken.capod.monitor.core.MonitorComponent
@InstallIn(MonitorComponent::class)
@EntryPoint
interface MonitorWorkerEntryPoint
@@ -20,6 +20,8 @@ import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.PodDevice
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@@ -29,6 +31,7 @@ class MonitorNotifications @Inject constructor(
private val notificationViewFactory: MonitorNotificationViewFactory
) {
private val builderLock = Mutex()
private val builder: NotificationCompat.Builder
init {
@@ -56,7 +59,7 @@ class MonitorNotifications @Inject constructor(
}
}
fun getBuilder(device: PodDevice?): NotificationCompat.Builder {
private fun getBuilder(device: PodDevice?): NotificationCompat.Builder {
if (device == null) {
return builder.apply {
setCustomContentView(null)
@@ -76,9 +79,13 @@ class MonitorNotifications @Inject constructor(
}
}
fun getNotification(podDevice: PodDevice?): Notification = getBuilder(podDevice).build()
suspend fun getNotification(podDevice: PodDevice?): Notification = builderLock.withLock {
getBuilder(podDevice).build()
}
fun getForegroundInfo(podDevice: PodDevice?): ForegroundInfo = getBuilder(podDevice).toForegroundInfo()
suspend fun getForegroundInfo(podDevice: PodDevice?): ForegroundInfo = builderLock.withLock {
getBuilder(podDevice).toForegroundInfo()
}
@SuppressLint("InlinedApi")
private fun NotificationCompat.Builder.toForegroundInfo(): ForegroundInfo = if (hasApiLevel(29)) {
@@ -0,0 +1,80 @@
package eu.darken.capod.troubleshooter.ui
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.navigation.popBackStack
import eu.darken.capod.common.uix.Fragment3
import eu.darken.capod.common.viewbinding.viewBinding
import eu.darken.capod.databinding.TroubleshooterFragmentBinding
import eu.darken.capod.troubleshooter.ui.TroubleShooterFragmentVM.BleState
import javax.inject.Inject
@AndroidEntryPoint
class TroubleShooterFragment : Fragment3(R.layout.troubleshooter_fragment) {
override val vm: TroubleShooterFragmentVM by viewModels()
override val ui: TroubleshooterFragmentBinding by viewBinding()
@Inject lateinit var webpageTool: WebpageTool
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
ui.toolbar.apply {
setupWithNavController(findNavController())
}
ui.bleIntroStartAction.setOnClickListener { vm.troubleShootBle() }
vm.bleState.observe2(ui) { state ->
bleIntroContainer.isVisible = state is BleState.Intro
bleProcessContainer.isVisible = state is BleState.Working
bleResultContainer.isVisible = state is BleState.Result
when (state) {
is BleState.Intro -> {}
is BleState.Working -> {
bleProcessHistory.apply {
text = ""
state.allSteps.forEachIndexed { index, s -> append("#$index: $s\n") }
}
}
is BleState.Result -> {
bleResultHistory.apply {
text = ""
state.history.forEachIndexed { index, s -> append("#$index: $s\n") }
}
when (state) {
is BleState.Result.Success -> {
bleResultAction.isVisible = false
bleResultTitle.text = getString(R.string.troubleshooter_ble_result_success_title)
bleResultBody.text = getString(R.string.troubleshooter_ble_result_success_body)
bleResultAction.text = getString(R.string.general_close_action)
bleResultAction.setOnClickListener { popBackStack() }
}
is BleState.Result.Failure -> {
bleResultTitle.text = getString(R.string.troubleshooter_ble_result_failure_title)
bleResultBody.text = when (state.failureType) {
BleState.Result.Failure.Type.PHONE -> getString(R.string.troubleshooter_ble_result_failure_phone_body)
BleState.Result.Failure.Type.HEADPHONES -> getString(R.string.troubleshooter_ble_result_failure_phone_headphones)
}
bleResultAction.text = getString(R.string.general_check_action)
bleResultAction.setOnClickListener { vm.troubleShootBle() }
}
}
}
}
}
super.onViewCreated(view, savedInstanceState)
}
}
@@ -0,0 +1,275 @@
package eu.darken.capod.troubleshooter.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.unknown.UnknownDevice
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
@HiltViewModel
class TroubleShooterFragmentVM @Inject constructor(
@Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
private val podMonitor: PodMonitor,
private val debugSettings: DebugSettings,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
private val _bleState = MutableStateFlow<BleState>(BleState.Intro())
val bleState = _bleState.asLiveData2()
init {
_bleState
.onEach { log(TAG) { "New BLE State: $it" } }
.launchIn(vmScope)
}
private fun progress(message: String) {
var state = _bleState.value
if (state !is BleState.Working) {
state = BleState.Working("Starting...")
}
_bleState.value = state.nextStep(message)
}
private fun success(message: String) {
var state = _bleState.value
if (state !is BleState.Working) {
state = BleState.Working("Starting...")
}
_bleState.value = state.toSuccess(message)
}
private fun failure(message: String, type: BleState.Result.Failure.Type) {
var state = _bleState.value
if (state !is BleState.Working) {
state = BleState.Working("Starting...")
}
_bleState.value = state.toFailure(message, type)
}
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
log(TAG) { "troubleShootBle()" }
generalSettings.scannerMode.value = ScannerMode.LOW_LATENCY
run {
progress("Checking for headphones...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
podMonitor.mainDevice.filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Headphones found, nothing to troubleshoot.")
return@launch
} else {
progress("Headphones not detected.\n")
}
}
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<PodDevice> = { hardwareFilteringDisabled,
hardwareBatchingDisabled,
indirectCallback,
unfiltered ->
val sb = StringBuilder("SCAN - Settings: ")
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
sb.append("indirectCallback=$indirectCallback, ")
sb.append("unfiltered=$unfiltered")
progress(sb.toString())
generalSettings.isOffloadedFilteringDisabled.value = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.value = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.value = indirectCallback
debugSettings.showUnfiltered.value = unfiltered
val start = System.currentTimeMillis()
val devices = withTimeoutOrNull(STEP_TIME) {
podMonitor.devices
.take(10)
.takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
devices
} else {
progress("SCAN: No data received")
devices
}
}
run {
progress("Checking if we can receive BLE data at all.")
if (doScan(false, false, false, true).isNotEmpty()) return@run
if (doScan(false, false, true, true).isNotEmpty()) return@run
if (doScan(true, true, true, true).isNotEmpty()) return@run
if (doScan(true, true, false, true).isNotEmpty()) return@run
if (doScan(true, false, true, true).isNotEmpty()) return@run
if (doScan(true, false, false, true).isNotEmpty()) return@run
if (doScan(false, true, true, true).isNotEmpty()) return@run
if (doScan(false, true, false, true).isNotEmpty()) return@run
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
generalSettings.isOffloadedFilteringDisabled.value = false
generalSettings.isOffloadedBatchingDisabled.value = false
generalSettings.useIndirectScanResultCallback.value = false
debugSettings.showUnfiltered.value = false
return@launch
}
progress("We received at least some BLE data.\n")
run {
progress("Checking for supported headphones.")
if (doScan(false, false, false, false).any { it !is UnknownDevice }) return@run
if (doScan(false, false, true, false).any { it !is UnknownDevice }) return@run
if (doScan(true, true, true, false).any { it !is UnknownDevice }) return@run
if (doScan(true, true, false, false).any { it !is UnknownDevice }) return@run
if (doScan(true, false, true, false).any { it !is UnknownDevice }) return@run
if (doScan(true, false, false, false).any { it !is UnknownDevice }) return@run
if (doScan(false, true, true, false).any { it !is UnknownDevice }) return@run
if (doScan(false, true, false, false).any { it !is UnknownDevice }) return@run
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
generalSettings.isOffloadedFilteringDisabled.value = false
generalSettings.isOffloadedBatchingDisabled.value = false
generalSettings.useIndirectScanResultCallback.value = false
return@launch
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
podMonitor.mainDevice.filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Found your headphones, new BLE settings worked :)!")
return@launch
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
var otherDevices = withTimeoutOrNull(STEP_TIME) {
podMonitor.devices.take(10).toList().flatten()
} ?: emptyList()
if (otherDevices.isEmpty()) {
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
return@launch
}
progress("Headphones found nearby, but not detected as yours. Resetting filters.\n")
generalSettings.mainDeviceModel.value = PodDevice.Model.UNKNOWN
generalSettings.mainDeviceAddress.value = null
generalSettings.minimumSignalQuality.value = 0.25f
otherDevices = withTimeoutOrNull(STEP_TIME) {
withTimeoutOrNull(STEP_TIME) {
val start = System.currentTimeMillis()
podMonitor.devices
.take(10)
.takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
} ?: emptyList()
progress("Setting headphone with strongest signal as yours.")
generalSettings.mainDeviceModel.value = otherDevices.maxBy { it.signalQuality }.model
val mainDevice = withTimeoutOrNull(STEP_TIME) {
podMonitor.mainDevice.filterNotNull().firstOrNull()
}
if (mainDevice != null) {
generalSettings.mainDeviceModel.value = mainDevice.model
generalSettings.scannerMode.value = ScannerMode.BALANCED
success("Success! Detected your headphones.")
return@launch
}
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
sealed class BleState {
class Intro : BleState()
data class Working(
val current: String,
val history: List<String> = emptyList(),
) : BleState() {
val allSteps: List<String>
get() = history + current
fun nextStep(message: String) = this.copy(
current = message,
history = history + current
)
fun toSuccess(message: String) = Result.Success(
history = history + message
)
fun toFailure(message: String, type: Result.Failure.Type) = Result.Failure(
failureType = type,
history = history + message,
)
}
sealed class Result : BleState() {
abstract val history: List<String>
data class Success(
override val history: List<String>,
) : Result()
data class Failure(
val failureType: Type,
override val history: List<String>,
) : Result() {
enum class Type {
PHONE,
HEADPHONES,
;
}
}
}
}
companion object {
const val STEP_TIME = 10 * 1000L // 6 scans per 30 seconds max
val TAG = logTag("TroubleShooter", "Fragment", "VM")
}
}
@@ -32,12 +32,22 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="16dp"
android:text="@string/overview_nomaindevice_description"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@id/troubleshoot_action"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_label" />
<com.google.android.material.button.MaterialButton
android:id="@+id/troubleshoot_action"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/troubleshoot_action"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,186 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.MaterialComponents.Toolbar.Primary"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="@string/troubleshooter_title" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:id="@+id/ble_card"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/ble_intro_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/troubleshooter_ble_intro_title" />
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/troubleshooter_ble_intro_body1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/ble_intro_start_action"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/troubleshooter_ble_intro_start_action" />
</LinearLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/ble_process_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:visibility="visible">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/ble_process_progress"
style="@style/Widget.Material3.CircularProgressIndicator.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="@id/ble_process_secondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/ble_process_primary" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_process_primary"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/troubleshooter_ble_process_title"
app:layout_constraintBottom_toTopOf="@id/ble_process_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/ble_process_progress"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_process_secondary"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/troubleshooter_ble_process_subtile"
app:layout_constraintBottom_toTopOf="@id/ble_process_history"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/ble_process_primary"
app:layout_constraintTop_toBottomOf="@id/ble_process_primary" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_process_history"
style="@style/TextAppearance.Material3.BodySmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ble_process_secondary"
tools:text="Step 1\nStep 2\n\Step 3" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/ble_result_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:visibility="visible">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_result_title"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/troubleshooter_ble_result_success_title" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_result_body"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/troubleshooter_ble_result_success_body" />
<com.google.android.material.button.MaterialButton
android:id="@+id/ble_result_action"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/troubleshooter_ble_result_failure_action" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_result_history"
style="@style/TextAppearance.Material3.BodySmall"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
tools:text="Step 1\nStep 2\n\Step 3" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -13,6 +13,9 @@
<action
android:id="@+id/action_overviewFragment_to_settingsFragment"
app:destination="@id/settingsFragment" />
<action
android:id="@+id/action_overviewFragment_to_troubleShooterFragment"
app:destination="@id/troubleShooterFragment" />
</fragment>
<fragment
@@ -20,5 +23,9 @@
android:name="eu.darken.capod.main.ui.settings.SettingsFragment"
android:label="SettingsFragment"
tools:layout="@layout/settings_fragment" />
<fragment
android:id="@+id/troubleShooterFragment"
android:name="eu.darken.capod.troubleshooter.ui.TroubleShooterFragment"
tools:layout="@layout/troubleshooter_fragment" />
</navigation>
+78 -1
View File
@@ -1,2 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"></resources>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="general_share_action">Partilhar</string>
<string name="general_done_action">OK</string>
<string name="general_copy_action">Copiar</string>
<string name="general_thank_you_label">Obrigado</string>
<string name="general_upgrade_action">Melhorar</string>
<string name="general_check_action">Verificar</string>
<string name="general_close_action">Fechar</string>
<string name="upgrade_capod_label">Melhorar CAPod</string>
<string name="upgrade_capod_description">Obtenha funcionalidades adicionais e apoie o programador.</string>
<string name="settings_monitor_mode_label">Modo de monitorização</string>
<string name="settings_monitor_mode_description">Sob quais circunstâncias esta aplicação monitora dados Bluetooth.</string>
<string name="settings_scanner_mode_label">Modo de scanner</string>
<string name="settings_scanner_mode_description">O scanner de dados \'Bluetooth Low Energy\' deve priorizar desempenho ou economizar energia?</string>
<string name="settings_autopause_label">Pausa automática</string>
<string name="settings_autopause_description">Pause o áudio ao remover o dispositivo do ouvido.</string>
<string name="settings_showall_label">Mostrar todos os dispositivos</string>
<string name="settings_showall_description">Mostrar os dispositivos de outras pessoas que estão perto de você.</string>
<string name="settings_autopplay_label">Reprodução automática</string>
<string name="settings_autoplay_description">Iniciar a reprodução de áudio quando o dispositivo for usado.</string>
<string name="settings_fake_data_label">Dados falsos</string>
<string name="settings_fake_data_description">Mostrar dados falsos, por exemplo, mostrar dispositivos que não existem.</string>
<string name="settings_debug_label">Definições de debug</string>
<string name="settings_debug_description">Definições adicionais para ajudar a resolver problemas com a aplicação.</string>
<string name="settings_signal_minimum_label">Qualidade de sinal mínima</string>
<string name="settings_signal_minimum_description">A qualidade de sinal mínima para o dispositivo ser considerado seu.</string>
<string name="settings_autoconnect_label">AutoConectar</string>
<string name="settings_autoconnect_description">Se o Android não conectar automaticamente, nós podemos pedir também. Isto irá alterar o modo de monitorização para \'Sempre\'.</string>
<string name="settings_autoconnect_condition_label">Condição para AutoConectar</string>
<string name="settings_autoconnect_condition_description">Quando é que devemos tentar conectar ao seu dispositivo?</string>
<string name="settings_reaction_label">Reações</string>
<string name="settings_reaction_description">Reagir a eventos e comportamentos.</string>
<string name="settings_category_yourdevice_label">O seu dispositivo</string>
<string name="settings_maindevice_address_label">Endereço do seu dispositivo</string>
<string name="settings_maindevice_address_description">O endereço do seu dispositivo emparelhado. A aplicação utiliza isto para determinar quando é que está conectado ao seu telemóvel.</string>
<string name="settings_maindevice_address_none">Nenhum</string>
<string name="settings_maindevice_model_label">Modelo do seu dispositivo</string>
<string name="settings_maindevice_model_description">O modelo do seu dispositivo principal. Isto ajuda a aplicação a reconhecer o seu dispositivo quando não está conectado ao seu telemóvel.</string>
<string name="settings_popup_caseopen_label">Mostrar pop-up</string>
<string name="settings_popup_caseopen_description">Mostrar pop-up quando a caixa do dispositivo está aberta (experimental).</string>
<string name="settings_onepod_mode_label">Modo de um pod</string>
<string name="settings_onepod_mode_description">Usar os dois pods não é necessário, usar um único pod é suficiente para desencadear reações.</string>
<string name="notification_channel_device_status_label">Estado do dispositivo</string>
<string name="debug_debuglog_size_label">Tamanho</string>
<string name="debug_debuglog_size_compressed_label">Tamanho comprimido</string>
<string name="debug_notification_channel_label">Notificações de debug</string>
<string name="debug_debuglog_file_label">Ficheiro de registo gravado</string>
<string name="debug_debuglog_record_action">Gravar ficheiro de registo</string>
<string name="settings_support_installid_label">ID de instalação</string>
<string name="settings_support_installid_desc">Os relatórios de erros automáticos são anónimos. Compartilhe o seu ID de instalação se o programador precisar encontrar os seus relatórios de erro.</string>
<string name="settings_support_label">Suporte</string>
<string name="settings_support_description">Se precisar de ajuda.</string>
<string name="issue_tracker_label">Rastreador de problemas</string>
<string name="issue_tracker_description">Um rastreador de problemas públicos para relatórios de erros e solicitações de recursos (somente em inglês).</string>
<string name="discord_label">Discord</string>
<string name="discord_description">Um lugar para conviver e tirar dúvidas.</string>
<string name="changelog_label">Registo de alterações</string>
<string name="settings_label">Definições</string>
<string name="settings_privacy_policy_label">Política de privacidade</string>
<string name="settings_privacy_policy_desc">Manuseio de dados com responsabilidade.</string>
<string name="settings_licenses_label">Licenças</string>
<string name="settings_category_other_label">Outro</string>
<string name="settings_general_label">Definições</string>
<string name="settings_general_description">Ajustes gerais que afetam a aplicação toda.</string>
<string name="settings_acknowledgements_label">Reconhecimentos</string>
<string name="settings_debug_autoreports_label">Relatar de erros automaticamente</string>
<string name="settings_debug_autoreports_description">Relata problemas automaticamente, por exemplo, detalhes sobre uma falha na aplicação para eu poder perceber como corrigi-lo.</string>
<string name="settings_debug_mode_label">Modo de debug</string>
<string name="settings_debug_mode_description">Mostrar informações adicionais para resolver problemas.</string>
<string name="settings_blescanner_unfiltered_label">Dados BLE sem filtro</string>
<string name="settings_blescanner_unfiltered_description">Remova qualquer filtro do scanner BLE para mostrar todos os dados BLE transmitidos. Útil para adicionar suporte para novos headphones.</string>
<string name="settings_compatibility_mode_label">Modo de compatibilidade</string>
<string name="settings_compatibility_mode_description">Desative as otimizações para melhorar a compatibilidade. Tente isso se não estiver a ver nenhum dado.</string>
<string name="help_translate_label">Tradução</string>
<string name="help_translate_description">Ajude a traduzir esta aplicação para o seu idioma favorito.</string>
<string name="translators_thanks_title">Tradutores</string>
<string name="translators_thanks_description">darken</string>
</resources>
+3
View File
@@ -53,6 +53,7 @@
<string name="settings_support_description">Yardıma ihtiyacın olursa.</string>
<string name="issue_tracker_label">Sorun izleyicisi</string>
<string name="issue_tracker_description">Hata raporları ve özellik istekleri için genel bir sorun izleyicisi (yalnızca İngilizce).</string>
<string name="discord_label">Discord</string>
<string name="discord_description">Takılıp soru sormak için bir yer.</string>
<string name="changelog_label">Değişiklik Günlüğü</string>
<string name="settings_label">Ayarlar</string>
@@ -73,4 +74,6 @@
<string name="settings_compatibility_mode_description">Uyumluluğu artırmak için iyileştirmeleri devre dışı bırakın. Herhangi bir veri görmüyorsanız bunu deneyin.</string>
<string name="help_translate_label">Tercüme</string>
<string name="help_translate_description">Bu uygulamayı en sevdiğiniz dile çevirmeye yardımcı olun.</string>
<string name="translators_thanks_title">Çevirmenler</string>
<string name="translators_thanks_description">karanlık</string>
</resources>
+15
View File
@@ -102,4 +102,19 @@
<string name="widget_description">A widget showing the last known device status.</string>
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>
<string name="troubleshooter_title">Troubleshooter</string>
<string name="troubleshooter_ble_intro_body1">AirPods broadcast their status information using a BLE technology called \"advertisements\". Some devices don\'t implement this technology correctly. This process will try different compatibility settings to fix the issue on your device. Setup your headphones to play music and place them close to your phone while this process is running.</string>
<string name="troubleshooter_ble_intro_title">Bluetooth Low Energy Broadcasts</string>
<string name="troubleshooter_ble_intro_start_action">Start investigating</string>
<string name="troubleshooter_ble_process_title">Troubleshoot in progress</string>
<string name="troubleshooter_ble_process_subtile">Each step takes 510 seconds</string>
<string name="troubleshooter_ble_result_success_title">Success</string>
<string name="troubleshooter_ble_result_success_body">BLE advertisement broadcasts are being received by CAPod.</string>
<string name="troubleshooter_ble_result_failure_title">Unsuccessful</string>
<string name="troubleshooter_ble_result_failure_body">No combination of compatibility options helped.</string>
<string name="troubleshooter_ble_result_failure_phone_body">Your phone didn\'t receive any BLE data at all. You can retry this test in a crowded area to see if data sources (other than your headphones) can be received. No data being received points towards an issue with your phones operating system.</string>
<string name="troubleshooter_ble_result_failure_phone_headphones">Your phone received BLE data, but the data does not come from any supported device. Are your headphones powered on? Does CAPod support your headphone?</string>
<string name="troubleshooter_ble_result_failure_action">Discuss on Discord</string>
<string name="troubleshoot_action">Troubleshoot</string>
</resources>
+1 -1
View File
@@ -4,7 +4,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:7.2.2")
classpath("com.android.tools.build:gradle:7.3.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.Kotlin.core}")
classpath("com.google.dagger:hilt-android-gradle-plugin:${Versions.Dagger.core}")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.AndroidX.Navigation.core}")
+2 -2
View File
@@ -8,7 +8,7 @@ repositories {
mavenCentral()
}
dependencies {
implementation("com.android.tools.build:gradle:7.2.2")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10")
implementation("com.android.tools.build:gradle:7.3.1")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0")
implementation("com.squareup:javapoet:1.13.0")
}
+2 -2
View File
@@ -84,14 +84,14 @@ fun DependencyHandlerScope.addBaseWorkManager() {
}
fun DependencyHandlerScope.addBaseAndroid() {
implementation("androidx.core:core-ktx:1.8.0")
implementation("androidx.core:core-ktx:1.10.0-rc01")
implementation("androidx.annotation:annotation:1.4.0")
implementation("androidx.collection:collection-ktx:1.2.0")
implementation("androidx.preference:preference-ktx:1.2.0")
}
fun DependencyHandlerScope.addBaseAndroidUi() {
implementation("androidx.appcompat:appcompat:1.6.0-alpha04")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.constraintlayout:constraintlayout:2.1.3")
implementation("androidx.fragment:fragment-ktx:1.4.1")
+3 -3
View File
@@ -4,7 +4,7 @@ object Versions {
}
object Kotlin {
const val core = "1.7.10"
const val core = "1.8.0"
const val coroutines = "1.6.2"
}
@@ -13,11 +13,11 @@ object Versions {
}
object Dagger {
const val core = "2.42"
const val core = "2.45"
}
object Moshi {
const val core = "1.13.0"
const val core = "1.14.0"
}
object AndroidX {
@@ -0,0 +1,2 @@
Bugfixes and performance improvements.
¯\_(ツ)_/¯
@@ -0,0 +1,2 @@
Bugfixes and performance improvements.
¯\_(ツ)_/¯
@@ -0,0 +1,2 @@
Bugfixes and performance improvements.
¯\_(ツ)_/¯
@@ -0,0 +1,2 @@
Bugfixes and performance improvements.
¯\_(ツ)_/¯
@@ -0,0 +1,2 @@
Bugfixes and performance improvements.
¯\_(ツ)_/¯
@@ -1,3 +1,2 @@
Now with more widgets!
But also bugfixes and performance improvements.
Bugfixes and performance improvements.
¯\_(ツ)_/¯
@@ -10,6 +10,9 @@ Features:
* Automatically connect phone and AirPods.
* Show popup when case is opened.
CAPod has a Wear-OS version that shows the status of the pod device that is closest to you.
CAPod for Wear-OS is a stand-alone app and does not require a phone.
CAPod is ad-free. Some features require an in-app purchase.
Most popular AirPods and Beats devices are supported.
+1 -1
View File
@@ -1 +1 @@
A CAPod az AirPods kísérőalkalmazása
Az AirPods kísérőalkalmazása
+1 -1
View File
@@ -1 +1 @@
CAPod - Companion app per le Airpods
Companion app per le Airpods
+1 -1
View File
@@ -1 +1 @@
CAPod - Complemento para AirPods
CAPod - Para AirPods
@@ -1,18 +1,18 @@
CAPod is a companion app for AirPods.
CAPod é uma aplicação complementar aos AirPods.
Features:
Funcionalidades:
* Battery level for pods and cases.
* Charging status for pods and case.
* Additional infos about connection, microphone and case.
* Can receive and show all nearby devices.
* Ear detection with automatic play/pause.
* Automatically connect phone and AirPods.
* Show popup when case is opened.
* Nível de bateria para os pods e a caixa.
* Estado de carregamento para os pods e a caixa.
* Informações adicionais sobre a conexão, o microfone e a caixa.
* Pode detetar e mostrar todos os dispositivos por perto.
* Deteção do ouvido com reprodução/pausa automática.
* Automaticamente conectar telemóvel e AirPods.
* Mostrar pop-up quando a caixa é aberta.
CAPod is ad-free. Some features require an in-app purchase.
CAPods é livre de anúncios. Algumas funcionalidades exigem uma compra na aplicação.
Most popular AirPods and Beats devices are supported.
If your device is similar to AirPods but not yet supported, send me a short mail.
Os dispositivos AirPods e Beats mais populares são suportados.
Se o seu dispositivo é semelhante aos AirPods, mas ainda não é suportado, envie-me um pequeno email.
Got a cool idea for a new feature? Reach out!
Tem alguma ideia fixe para uma nova funcionalidade? Contacte-me!
@@ -1 +1 @@
CAPod is a companion app for AirPods on Android.
CAPod é uma aplicação complementar aos AirPods no Android.
+1 -1
View File
@@ -1 +1 @@
CAPod - Companion for AirPods
CAPod - Para AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - Bạn đồng hành cho AirPods
Bạn đồng hành cho AirPods
+20
View File
@@ -360,6 +360,25 @@ do-versionfile() {
git add VERSION
}
# Update a version file that can be parsed by third-parties, e.g. F-Droid
do-fastlane-changelog() {
CHANGELOGS_DIR="fastlane/metadata/android/en-US/changelogs"
NEW_CHANGELOG="$CHANGELOGS_DIR/$V_CODE.txt"
[ -f "$NEW_CHANGELOG" ] && return
echo -e "\n${S_NOTICE}Creating new default fastlane changelog file for $V_CODE...\n"
cp "$CHANGELOGS_DIR/default.txt" "$NEW_CHANGELOG"
cat "$NEW_CHANGELOG"
echo -e "\n\n${I_OK} ${S_NOTICE}${ACTION_MSG} [${S_NORM}$NEW_CHANGELOG${S_NOTICE}] file"
# Stage file for commit
git add "$CHANGELOGS_DIR/$V_CODE.txt"
}
# Does the release branch already exist?
check-branch-exist() {
[ "$FLAG_NOBRANCH" = true ] && return
@@ -448,6 +467,7 @@ echo -e "\n${S_LIGHT}––––––"
# Update steps
do-version-properties
do-versionfile
do-fastlane-changelog
do-branch
do-commit
create-tag "${V_NAME}" "${REL_NOTE}"
+1 -1
View File
@@ -1,6 +1,6 @@
### Updated by release.sh ###
project.versioning.major=2
project.versioning.minor=8
project.versioning.minor=9
project.versioning.patch=0
project.versioning.build=0
#############################