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.