diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 830a6565..cc54e9c1 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -41,7 +41,7 @@ android {
defaultConfig {
applicationId = "me.kavishdevar.librepods"
targetSdk = 37
- versionCode = 53
+ versionCode = 55
versionName = appVersionName
}
buildTypes {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt
index c96c9f7d..1b4fb5c2 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt
@@ -23,10 +23,14 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.content.Context.MODE_PRIVATE
+import android.content.Intent
import android.content.SharedPreferences
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -51,6 +55,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
@@ -64,6 +69,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.net.toUri
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
@@ -93,6 +99,7 @@ import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+import java.util.concurrent.TimeUnit
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -170,6 +177,44 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
}
} else Modifier)) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
+
+ item(key = "play_update_banner") {
+ if (state.timeUntilFOSSPremiumExpiry > 0L) {
+ val context = LocalContext.current
+ Box(
+ modifier = Modifier
+ .background(Color(0xFF32829B), RoundedCornerShape(28.dp))
+ .clip(RoundedCornerShape(28.dp))
+ .clickable {
+ val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
+ data = "mailto:".toUri()
+ putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
+ putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
+ putExtra(
+ Intent.EXTRA_TEXT,
+ "Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
+ )
+ }
+ context.startActivity(emailIntent)
+ }
+ ) {
+ Text(
+ text = stringResource(
+ R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
+ ),
+ modifier = Modifier
+ .padding(16.dp),
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ }
+ }
+ }
+
item(key = "battery") {
BatteryView(
batteryList = state.battery,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
index 752e4a1b..af1973d1 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
@@ -24,6 +24,7 @@ import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -91,6 +92,7 @@ import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
+import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -147,7 +149,39 @@ fun AppSettingsScreen(
)
}
}
-
+ if (state.timeUntilFOSSPremiumExpiry > 0L) {
+ Box(
+ modifier = Modifier
+ .background(Color(0xFF32829B), RoundedCornerShape(28.dp))
+ .clip(RoundedCornerShape(28.dp))
+ .clickable {
+ val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
+ data = "mailto:".toUri()
+ putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
+ putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
+ putExtra(
+ Intent.EXTRA_TEXT,
+ "Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
+ )
+ }
+ context.startActivity(emailIntent)
+ }
+ ) {
+ Text(
+ text = stringResource(
+ R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
+ ),
+ modifier = Modifier
+ .padding(16.dp),
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ }
+ }
if (state.connectionSuccessful) {
StyledToggle(
title = stringResource(R.string.widget),
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt
index e60d38e9..ae5cefe9 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt
@@ -53,11 +53,11 @@ import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
-import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
+import me.kavishdevar.librepods.utils.XposedState
@Composable
fun PurchaseScreen(
@@ -199,7 +199,7 @@ fun PurchaseScreen(
)
)
}
- if (BuildConfig.FLAVOR == "xposed") {
+ if (XposedState.isAvailable) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
index 2b184651..45752ecc 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
@@ -93,7 +94,8 @@ data class AirPodsUiState(
val dynamicEndOfCharge: Boolean = false,
- val connectionSuccessful: Boolean = false
+ val connectionSuccessful: Boolean = false,
+ val timeUntilFOSSPremiumExpiry: Long = 0L
)
class AirPodsViewModel(
@@ -142,9 +144,10 @@ class AirPodsViewModel(
loadInstance()
loadSharedPreferences()
setupControlObservers()
- observeBilling()
loadControlList()
observeATT()
+ observeSharedPreferences()
+ observeBilling()
if (isDemoMode) activateDemoMode()
}
@@ -172,18 +175,38 @@ class AirPodsViewModel(
// billingFirstCollectDone = true
// return@collect
// }
- if (!premium) {
- setControlCommandBoolean(
- ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
- false
- )
- setHeadGesturesEnabled(false)
+ if (premium) {
+ sharedPreferences.edit {
+ remove("premium_expiry_time")
+ remove("foss_upgraded")
+ }
+ _uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
+ } else {
+ if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
+ setControlCommandBoolean(
+ ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
+ false
+ )
+ setHeadGesturesEnabled(false)
+ _uiState.update { it.copy(isPremium = false) }
+ }
}
- _uiState.update { it.copy(isPremium = premium) }
}
}
}
+ private fun observeSharedPreferences() {
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ when (key) {
+ "name" -> loadName()
+ "off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
+ "head_gestures", "left_long_press_action", "right_long_press_action",
+ "dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences()
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ }
+
private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -358,6 +381,7 @@ class AirPodsViewModel(
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
+ val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
_uiState.update {
it.copy(
offListeningMode = offListeningModeEnabled,
@@ -368,9 +392,56 @@ class AirPodsViewModel(
rightAction = rightAction,
vendorIdHook = vendorIdHook,
dynamicEndOfCharge = dynamicEndOfCharge,
- connectionSuccessful = connectionSuccessful
+ connectionSuccessful = connectionSuccessful,
)
}
+
+ // faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
+
+ val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
+ val now = System.currentTimeMillis()
+
+ when {
+ // existing temporary premium
+ expiryTime > 0L -> {
+ if (expiryTime <= now) {
+ sharedPreferences.edit {
+ remove("premium_expiry_time")
+ remove("foss_upgraded")
+ }
+
+ _uiState.update {
+ it.copy(
+ timeUntilFOSSPremiumExpiry = 0L,
+ isPremium = false
+ )
+ }
+ } else {
+ _uiState.update {
+ it.copy(
+ timeUntilFOSSPremiumExpiry = expiryTime - now,
+ isPremium = true
+ )
+ }
+ }
+ }
+
+ // First migration from accidental FOSS Play build
+ fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
+ val newExpiry = now + 28L * 24 * 60 * 60 * 1000
+
+ sharedPreferences.edit {
+ putLong("premium_expiry_time", newExpiry)
+ }
+
+ _uiState.update {
+ it.copy(
+ timeUntilFOSSPremiumExpiry = newExpiry - now,
+ isPremium = true
+ )
+ }
+ }
+ }
}
fun setOffListeningMode(enabled: Boolean) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt
index b662b60b..168e98b2 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import kotlin.math.roundToInt
@@ -34,7 +35,8 @@ data class AppSettingsUiState(
val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false,
val showBottomSheetPopup: Boolean = true,
- val showIslandPopup: Boolean = true
+ val showIslandPopup: Boolean = true,
+ val timeUntilFOSSPremiumExpiry: Long = 0L
)
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -66,12 +68,71 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
- _uiState.update { it.copy(isPremium = premium) }
+ if (premium) {
+ sharedPreferences.edit {
+ remove("premium_expiry_time")
+ remove("foss_upgraded")
+ }
+ _uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
+ } else {
+ // No billing premium, only update if no temporary premium is active
+ if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
+ _uiState.update { it.copy(isPremium = false) }
+ }
+ }
}
}
}
private fun loadSettings() {
+ // faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
+
+ val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
+ val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
+ val now = System.currentTimeMillis()
+
+ when {
+ // existing temporary premium
+ expiryTime > 0L -> {
+ if (expiryTime <= now) {
+ sharedPreferences.edit {
+ remove("premium_expiry_time")
+ remove("foss_upgraded")
+ }
+
+ _uiState.update {
+ it.copy(
+ timeUntilFOSSPremiumExpiry = 0L,
+ isPremium = false
+ )
+ }
+ } else {
+ _uiState.update {
+ it.copy(
+ timeUntilFOSSPremiumExpiry = expiryTime - now,
+ isPremium = true
+ )
+ }
+ }
+ }
+
+ // First migration from accidental FOSS Play build
+ fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
+ val newExpiry = now + 28L * 24 * 60 * 60 * 1000
+
+ sharedPreferences.edit {
+ putLong("premium_expiry_time", newExpiry)
+ }
+
+ _uiState.update {
+ it.copy(
+ timeUntilFOSSPremiumExpiry = newExpiry - now,
+ isPremium = true
+ )
+ }
+ }
+ }
+
_uiState.update { currentState ->
currentState.copy(
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
index ecc4b553..0b074a83 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
@@ -1094,9 +1094,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
)
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
- if (BuildConfig.FLAVOR == "xposed") {
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
- }
} else {
val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 0130fc93..9328137c 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -276,4 +276,5 @@
Optimized Charge Limit
AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version.
Enable LibrePods in Xposed or update your device to proceed.
+ Due to an error in billing, premium access will expire in %1$d days. If you already upgraded the app, please click on this message to email billing@kavish.xyz to restore or verify access. Apologies for the inconvenience.