From d9a9cd85303752805159ebca6733e1d4efaee143 Mon Sep 17 00:00:00 2001 From: Zakir Ahmad Sheikh Date: Sun, 21 Aug 2022 19:04:17 +0530 Subject: [PATCH] * Added Support for Google Play in-App Purchases. * Added a way to remove ads. * Bug Fixes. --- app/build.gradle | 17 +- .../java/com/prime/player/MainActivity.kt | 329 +++++++-------- .../main/java/com/prime/player/audio/Home.kt | 8 +- .../com/prime/player/audio/console/Console.kt | 32 +- .../com/prime/player/audio/library/Library.kt | 23 +- .../prime/player/common/billing/Advertiser.kt | 4 + .../player/common/billing/BillingManager.kt | 378 ++++++++++++++++++ .../prime/player/common/billing/Security.java | 125 ++++++ .../com/prime/player/common/compose/More.kt | 21 +- .../java/com/prime/player/core/LocalDb.kt | 7 +- .../java/com/prime/player/core/Repository.kt | 4 +- .../com/prime/player/settings/GlobalKeys.kt | 12 +- .../com/prime/player/settings/Settings.kt | 9 +- app/src/main/res/drawable/ic_remove_ads.xml | 4 + 14 files changed, 736 insertions(+), 237 deletions(-) create mode 100644 app/src/main/java/com/prime/player/common/billing/Advertiser.kt create mode 100644 app/src/main/java/com/prime/player/common/billing/BillingManager.kt create mode 100644 app/src/main/java/com/prime/player/common/billing/Security.java create mode 100644 app/src/main/res/drawable/ic_remove_ads.xml diff --git a/app/build.gradle b/app/build.gradle index 02fe3f6..98705b9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,8 +14,8 @@ android { applicationId "com.prime.player" minSdk 21 targetSdk 33 - versionCode 7 - versionName "1.0.0-beta03" + versionCode 9 + versionName "1.0.0-beta04" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -116,13 +116,13 @@ dependencies { implementation("androidx.room:room-ktx:$room_version") // The Accompanist Libraries - def accompanist_version = '0.26.0-alpha' + def accompanist_version = '0.26.1-alpha' implementation "com.google.accompanist:accompanist-insets:$accompanist_version" //implementation "com.google.accompanist:accompanist-coil:$accompanist_version" implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" implementation "com.google.accompanist:accompanist-navigation-animation:$accompanist_version" - implementation('io.coil-kt:coil-compose:2.1.0') + implementation('io.coil-kt:coil-compose:2.2.0') implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" //hilt @@ -138,7 +138,7 @@ dependencies { implementation "com.airbnb.android:lottie-compose:$lottie_version" // kotlin json serialization library - implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0' //palette to extract image colors implementation "androidx.palette:palette-ktx:1.0.0" @@ -166,7 +166,7 @@ dependencies { implementation "com.github.levinzonr.compose-safe-routing:accompanist-navigation:$safe_args_version" // Import the Firebase BoM - implementation platform('com.google.firebase:firebase-bom:30.3.1') + implementation platform('com.google.firebase:firebase-bom:30.3.2') // Add the dependency for the Firebase SDK for Google Analytics // When using the BoM, don't specify versions in Firebase dependencies implementation 'com.google.firebase:firebase-analytics-ktx' @@ -189,4 +189,9 @@ dependencies { def in_app_review = "2.0.0" implementation("com.google.android.play:review:$in_app_review") implementation("com.google.android.play:review-ktx:$in_app_review") + + // google play in-app billing + def billing_version = "5.0.0" + implementation "com.android.billingclient:billing:$billing_version" + implementation "com.android.billingclient:billing-ktx:$billing_version" } \ No newline at end of file diff --git a/app/src/main/java/com/prime/player/MainActivity.kt b/app/src/main/java/com/prime/player/MainActivity.kt index 8a420b2..efd5ee5 100644 --- a/app/src/main/java/com/prime/player/MainActivity.kt +++ b/app/src/main/java/com/prime/player/MainActivity.kt @@ -1,6 +1,5 @@ package com.prime.player - import android.animation.ObjectAnimator import android.app.Activity import android.database.ContentObserver @@ -46,6 +45,7 @@ import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.ktx.analytics import com.google.firebase.ktx.Firebase import com.prime.player.audio.Home +import com.prime.player.common.billing.BillingManager import com.prime.player.common.compose.* import com.prime.player.core.SyncWorker import com.prime.player.settings.GlobalKeys @@ -64,25 +64,28 @@ import javax.inject.Inject private const val TAG = "MainActivity" -private val KEY_LAUNCH_COUNTER = intPreferenceKey(TAG + "_launch_counter") -private val KEY_LAST_REVIEW_TIME = longPreferenceKey(TAG + "_last_review_time") - private const val RESULT_CODE_APP_UPDATE = 1000 @AndroidEntryPoint class MainActivity : ComponentActivity() { lateinit var fAnalytics: FirebaseAnalytics - lateinit var mAppUpdateManager: AppUpdateManager private lateinit var observer: ContentObserver + lateinit var mReviewManager: ReviewManager @Inject lateinit var preferences: Preferences + override fun onDestroy() { + // unregister content Observer. + //if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) + contentResolver.unregisterContentObserver(observer) + super.onDestroy() + } + @OptIn(ExperimentalPermissionsApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // The app has started from scratch if savedInstanceState is null. val isColdStart = savedInstanceState == null //why? // Obtain the FirebaseAnalytics instance. @@ -91,26 +94,17 @@ class MainActivity : ComponentActivity() { initSplashScreen( isColdStart ) - - //init - mAppUpdateManager = AppUpdateManagerFactory.create(this) - val reviewManager = ReviewManagerFactory.create(this) + mReviewManager = ReviewManagerFactory.create(this) val channel = SnackDataChannel() - - //schedule sync - // trigger sync worker once change in MediaStore is detected. - /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) SyncWorker.schedule(this) - else { - - }*/ - observer = object : ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - // run worker when change is detected. - if (!selfChange) SyncWorker.run(this@MainActivity) + //Observe the MediaStore + observer = + object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + // run worker when change is detected. + if (!selfChange) SyncWorker.run(this@MainActivity) + } } - } - // observe Images in MediaStore. contentResolver.registerContentObserver( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, @@ -118,35 +112,31 @@ class MainActivity : ComponentActivity() { observer, ) - - // launch - if (isColdStart && !BuildConfig.DEBUG) - lifecycleScope.launch { - // increment launch counter - val counter = with(preferences) { preferences[KEY_LAUNCH_COUNTER].obtain() } ?: 0 - // update launch counter if - // cold start. - preferences[KEY_LAUNCH_COUNTER] = counter + 1 - - // check for updates on startup - // don't report - // check silently - mAppUpdateManager.check( - channel = channel, - activity = this@MainActivity - ) - - val isAvailable = mAppUpdateManager.requestAppUpdateInfo().updateAvailability() == - UpdateAvailability.UPDATE_AVAILABLE - // don't ask - // if update is available - // because maybe the user wishes to update the app - // this might interrupt the reviewFlow. - if (!isAvailable) - reviewManager.review(this@MainActivity, preferences) - } + if (isColdStart) { + val counter = + with(preferences) { preferences[GlobalKeys.KEY_LAUNCH_COUNTER].obtain() } ?: 0 + // update launch counter if + // cold start. + preferences[GlobalKeys.KEY_LAUNCH_COUNTER] = counter + 1 + // check for updates on startup + // don't report + // check silently + launchUpdateFlow(channel) + // TODO: Try to reconcile if it is any good to ask for reviews here. + // launchReviewFlow() + } WindowCompat.setDecorFitsSystemWindows(window, false) + // setup billing manager + + val billingManager = + BillingManager( + context = this, + products = arrayOf( + BillingTokens.DISABLE_ASD_IN_APP_PRODUCT + ) + ) + lifecycle.addObserver(billingManager) setContent { val sWindow = rememberWindowSizeClass() @@ -164,9 +154,8 @@ class MainActivity : ComponentActivity() { LocalWindowSizeClass provides sWindow, LocalPreferenceStore provides preferences, LocalDensity provides modified, - LocalAppUpdateManager provides mAppUpdateManager, - LocalAppReviewManager provides reviewManager, LocalSnackDataChannel provides channel, + LocalBillingManager provides billingManager, LocalSystemUiController provides rememberSystemUiController() ) { Material(isDark = resolveAppThemeState()) { @@ -186,16 +175,9 @@ class MainActivity : ComponentActivity() { } } } - - override fun onDestroy() { - // unregister content Observer. - //if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) - contentResolver.unregisterContentObserver(observer) - super.onDestroy() - } - } + @Composable private fun PermissionRationale( onRequestPermission: () -> Unit @@ -262,137 +244,126 @@ private fun resolveAppThemeState(): Boolean { } -private const val FLEXIBLE_UPDATE_MAX_STALENESS_DAYS = 2 - -/** - * @param report simple messages. - */ -suspend fun AppUpdateManager.check( - channel: SnackDataChannel, - activity: Activity, - report: Boolean = false, -) { - requestUpdateFlow() - // catch any error like the install exception occuring - // in the release version of the app. - .catch { Log.i(TAG, "check: ${it.message}") } - // collect the flow and publish/request/install the update. - .collect { result -> - when (result) { - AppUpdateResult.NotAvailable -> if (report) - channel.send("The app is already updated to the latest version.") - - is AppUpdateResult.InProgress -> { - //FixMe: Publish progress - val state = result.installState - val progress = - state.bytesDownloaded() / (state.totalBytesToDownload() + 0.1) * 100 - Log.i(TAG, "check: $progress") - // currently don't show any message - // future version find ways to show progress. - } - is AppUpdateResult.Downloaded -> { - val info = requestAppUpdateInfo() - //when update first becomes available - //don't force it. - // make it required when staleness days overcome allowed limit - val isFlexible = - (info.clientVersionStalenessDays() ?: -1) <= - FLEXIBLE_UPDATE_MAX_STALENESS_DAYS - - // forcefully update; if it's flexible - if (!isFlexible) - completeUpdate() - else - // ask gracefully - channel.send( - message = "An update has just been downloaded.", - label = "RESTART", - action = this::completeUpdate, - duration = SnackbarDuration.Indefinite - ) - // no message needs to be shown - } - is AppUpdateResult.Available -> { - val isFlexible = - (result.updateInfo.clientVersionStalenessDays() ?: -1) <= - FLEXIBLE_UPDATE_MAX_STALENESS_DAYS - val result2 = - com.primex.core.runCatching(TAG) { - if (isFlexible) - result.startFlexibleUpdate( - activity = activity, - RESULT_CODE_APP_UPDATE - ) - else - result.startImmediateUpdate( - activity = activity, - RESULT_CODE_APP_UPDATE - ) - } - Log.i(TAG, "check: starting $result2") - // no message needs to be shown - } - } - } -} - - private const val MIN_LAUNCH_COUNT = 20 private val MAX_DAYS_BEFORE_FIRST_REVIEW = TimeUnit.DAYS.toMillis(7) -private val MAX_DAY_AFTER_FIRST_REVIEW = TimeUnit.DAYS.toMillis(20) /** - * Launch app review subject with condition. - * @param activity The activity to launch on. - * @param preferences The [Preferences] used to retrieve and stored values. - * @param force if force all the manually applied conditions will be forgotten and + * A convince method for launching an in-app review. + * The review API is guarded by some conditions which are + * * The first review will be asked when launchCount is > [MIN_LAUNCH_COUNT] and daysPassed >=[MAX_DAYS_BEFORE_FIRST_REVIEW] + * * After asking first review then after each [MAX_DAY_AFTER_FIRST_REVIEW] a review dialog will be showed. + * Note: The review should not be asked after every coldBoot. */ -suspend fun ReviewManager.review( - activity: Activity, - preferences: Preferences, - force: Boolean = false -) { - val count = with(preferences) { preferences[KEY_LAUNCH_COUNTER].obtain() } ?: 0 - // the time when lastly asked for review - val lastAskedTime = - with(preferences) { preferences[KEY_LAST_REVIEW_TIME].obtain() } +fun Activity.launchReviewFlow() { + require(this is MainActivity) + val count = + with(preferences) { preferences[GlobalKeys.KEY_LAUNCH_COUNTER].obtain() } ?: 0 val firstInstallTime = com.primex.core.runCatching(TAG + "_review") { - activity.packageManager.getPackageInfo(activity.packageName, 0).firstInstallTime + packageManager.getPackageInfo(packageName, 0).firstInstallTime } - val currentTime = System.currentTimeMillis() - val askFirstReview = - lastAskedTime == null && - firstInstallTime != null && - count >= MIN_LAUNCH_COUNT && - currentTime - firstInstallTime >= MAX_DAYS_BEFORE_FIRST_REVIEW - - val askBiggerOne = - lastAskedTime != null && - count >= MIN_LAUNCH_COUNT && - currentTime - lastAskedTime >= MAX_DAY_AFTER_FIRST_REVIEW - - // if any one is true ask - if (force || askFirstReview || askBiggerOne) - ask(activity, preferences) - /*val fAnalystics = Firebase.analytics - fAnalystics.("Review Forced: $force, First: $askFirstReview, lastAsked: $askBiggerOne")*/ -} + val currentTime = System.currentTimeMillis() -private suspend inline fun ReviewManager.ask( - activity: Activity, - preferences: Preferences -) { + // Only first time we should not ask immediately + // however other than this whenever we do some thing of appreciation. + // we should ask for review. + // after first review, it is safe to call it n number of times as the quota is managed by API. + val ask = firstInstallTime != null && + count >= MIN_LAUNCH_COUNT && + currentTime - firstInstallTime >= MAX_DAYS_BEFORE_FIRST_REVIEW // The flow has finished. The API does not indicate whether the user // reviewed or not, or even whether the review dialog was shown. Thus, no // matter the result, we continue our app flow. - com.primex.core.runCatching(TAG) { - // update the last asking - preferences[KEY_LAST_REVIEW_TIME] = System.currentTimeMillis() - val info = requestReview() - launchReviewFlow(activity, info) + lifecycleScope.launch { + if (ask) { + com.primex.core.runCatching(TAG) { + // update the last asking + val info = mReviewManager.requestReview() + mReviewManager.launchReviewFlow(this@launchReviewFlow, info) + //host.fAnalytics. + } + } + } +} + +private const val FLEXIBLE_UPDATE_MAX_STALENESS_DAYS = 2 + +/** + * A utility method to check for updates. + * @param channel [SnackDataChannel] to report errors, inform users about the availability of update. + * @param report simple messages. + */ +fun Activity.launchUpdateFlow( + channel: SnackDataChannel, + report: Boolean = false, +) { + require(this is MainActivity) + lifecycleScope.launch { + val manager = AppUpdateManagerFactory.create(this@launchUpdateFlow) + with(manager) { + val result = + kotlin.runCatching { + requestUpdateFlow() + .collect { result -> + when (result) { + AppUpdateResult.NotAvailable -> if (report) + channel.send("The app is already updated to the latest version.") + is AppUpdateResult.InProgress -> { + //FixMe: Publish progress + val state = result.installState + val progress = + state.bytesDownloaded() / (state.totalBytesToDownload() + 0.1) * 100 + Log.i(TAG, "check: $progress") + // currently don't show any message + // future version find ways to show progress. + } + is AppUpdateResult.Downloaded -> { + val info = requestAppUpdateInfo() + //when update first becomes available + //don't force it. + // make it required when staleness days overcome allowed limit + val isFlexible = + (info.clientVersionStalenessDays() ?: -1) <= + FLEXIBLE_UPDATE_MAX_STALENESS_DAYS + + // forcefully update; if it's flexible + if (!isFlexible) + completeUpdate() + else + // ask gracefully + channel.send( + message = "An update has just been downloaded.", + label = "RESTART", + action = this::completeUpdate, + duration = SnackbarDuration.Indefinite + ) + // no message needs to be shown + } + is AppUpdateResult.Available -> { + // if user choose to skip the update handle that case also. + val isFlexible = + (result.updateInfo.clientVersionStalenessDays() + ?: -1) <= + FLEXIBLE_UPDATE_MAX_STALENESS_DAYS + if (isFlexible) + result.startFlexibleUpdate( + activity = this@launchUpdateFlow, + RESULT_CODE_APP_UPDATE + ) + else + result.startImmediateUpdate( + activity = this@launchUpdateFlow, + RESULT_CODE_APP_UPDATE + ) + // no message needs to be shown + } + } + } + } + Log.d(TAG, "launchUpdateFlow() returned: $result") + } } -} \ No newline at end of file +} + diff --git a/app/src/main/java/com/prime/player/audio/Home.kt b/app/src/main/java/com/prime/player/audio/Home.kt index c10f817..fcc1c46 100644 --- a/app/src/main/java/com/prime/player/audio/Home.kt +++ b/app/src/main/java/com/prime/player/audio/Home.kt @@ -43,11 +43,11 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalAnimationApi::class) private val EnterTransition = scaleIn( - initialScale = 0.96f, - animationSpec = tween(180, delayMillis = 90) -) + fadeIn() + initialScale = 0.98f, + animationSpec = tween(220, delayMillis = 90) +) + fadeIn(animationSpec = tween(700)) -private val ExitTransition = fadeOut() +private val ExitTransition = fadeOut(tween(700)) @OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) @Composable diff --git a/app/src/main/java/com/prime/player/audio/console/Console.kt b/app/src/main/java/com/prime/player/audio/console/Console.kt index 9573930..153979c 100644 --- a/app/src/main/java/com/prime/player/audio/console/Console.kt +++ b/app/src/main/java/com/prime/player/audio/console/Console.kt @@ -55,10 +55,7 @@ import com.prime.player.common.share import com.prime.player.core.Audio import com.prime.player.core.playback.PlaybackService import com.prime.player.settings.GlobalKeys -import com.primex.core.drawHorizontalDivider -import com.primex.core.horizontalGradient -import com.primex.core.radialGradient -import com.primex.core.rememberState +import com.primex.core.* import com.primex.core.shadow.SpotLight import com.primex.core.shadow.shadow import com.primex.preferences.LocalPreferenceStore @@ -81,6 +78,7 @@ private fun ConsoleViewModel.MiniLayout( val color = Material.colors.surface createHorizontalChain(Artwork, Title, Heart, Play) + val activity = LocalContext.current.activity!! //artwork val artwork by artwork Image( @@ -128,7 +126,7 @@ private fun ConsoleViewModel.MiniLayout( val favourite by favourite IconButton( - onClick = { toggleFav() }, + onClick = { toggleFav(); activity.launchReviewFlow() }, painter = painterResource(id = if (favourite) R.drawable.ic_heart_filled else R.drawable.ic_heart), modifier = Modifier.constrainAs(Heart) { top.linkTo(parent.top) @@ -140,7 +138,7 @@ private fun ConsoleViewModel.MiniLayout( //play/pause val playing by playing IconButton( - onClick = { togglePlay() }, + onClick = { togglePlay(); activity.launchReviewFlow() }, contentDescription = null, painter = rememberAnimatedVectorPainter( id = R.drawable.avd_pause_to_play, @@ -272,6 +270,7 @@ private fun ConsoleViewModel.More( SleepTimer(expanded = showSleepMenu) { showSleepMenu = false } + val activity = LocalContext.current.activity!! var showPlaylistViewer by rememberState(initial = false) val playlists by playlists.collectAsState(initial = emptyList()) @@ -314,7 +313,7 @@ private fun ConsoleViewModel.More( MenuItem( vector = rememberVectorPainter(image = Icons.Outlined.PlaylistAdd), label = "Add to playlist", - onClick = { showPlaylistViewer = true; onDismissRequest() } + onClick = { showPlaylistViewer = true; activity.launchReviewFlow(); onDismissRequest(); } ) MenuItem( @@ -338,7 +337,7 @@ private fun ConsoleViewModel.More( MenuItem( vector = rememberVectorPainter(image = Icons.Outlined.ModeNight), label = "Sleep timer", - onClick = { showSleepMenu = true; onDismissRequest() } + onClick = { showSleepMenu = true; onDismissRequest(); } ) val context = LocalContext.current @@ -359,7 +358,7 @@ private fun ConsoleViewModel.More( val shuffle by shuffle IconButton( - onClick = { toggleShuffle(); onDismissRequest() }, + onClick = { toggleShuffle(); activity.launchReviewFlow(); onDismissRequest() }, painter = painterResource(id = R.drawable.ic_shuffle), contentDescription = null, tint = LocalContentColor.current.copy(if (shuffle) ContentAlpha.high else ContentAlpha.disabled) @@ -367,7 +366,7 @@ private fun ConsoleViewModel.More( val mode by repeatMode IconButton( - onClick = { cycleRepeatMode(); onDismissRequest() }, + onClick = { cycleRepeatMode();activity.launchReviewFlow(); onDismissRequest(); }, painter = painterResource(id = if (mode == PlaybackService.REPEAT_MODE_THIS) R.drawable.ic_repeat_one else R.drawable.ic_repeat), contentDescription = null, tint = LocalContentColor.current.copy( @@ -377,7 +376,7 @@ private fun ConsoleViewModel.More( ) IconButton( - onClick = { showPlayingQueue = true; onDismissRequest() }, + onClick = { showPlayingQueue = true; activity.launchReviewFlow(); onDismissRequest() }, imageVector = Icons.Outlined.PlaylistPlay, contentDescription = null ) @@ -391,6 +390,7 @@ private fun ConsoleViewModel.SleepTimer( expanded: Boolean, onDismissRequest: () -> Unit ){ + val activity = LocalContext.current.activity!! DropdownMenu( title = "Sleep Timer", preserveIconSpace = true, @@ -414,6 +414,7 @@ private fun ConsoleViewModel.SleepTimer( else -> error("No such value !!") } setSleepAfter(minutes) + activity.launchReviewFlow() } } @@ -478,6 +479,7 @@ private fun ConsoleViewModel.Layout( val (Signature, PlaylistLabel, ArtistLabel, Artwork, Slider, Album, Title, Play, UpNextLabel, UpNext) = createRefs() val primary = Material.colors.primary + val activity = LocalContext.current.activity!! // Signature Text( text = stringResource(id = R.string.app_name), @@ -672,7 +674,7 @@ private fun ConsoleViewModel.Layout( val (Heart, More) = createRefs() val favourite by favourite IconButton( - onClick = { toggleFav() }, + onClick = { toggleFav(); activity.launchReviewFlow() }, painter = painterResource(id = if (favourite) R.drawable.ic_heart_filled else R.drawable.ic_heart), contentDescription = null, modifier = Modifier.constrainAs(Heart) { @@ -741,7 +743,7 @@ private fun ConsoleViewModel.Layout( val (SkipToNext, SkipToPrev) = createRefs() createHorizontalChain(SkipToPrev, Play, SkipToNext, chainStyle = ChainStyle.Packed) NeuButton( - onClick = { togglePlay() }, + onClick = { togglePlay(); activity.launchReviewFlow() }, painter = rememberAnimatedVectorPainter( id = R.drawable.avd_pause_to_play, @@ -757,7 +759,7 @@ private fun ConsoleViewModel.Layout( ) NeuButton( - onClick = { skipToPrev() }, + onClick = { skipToPrev(); activity.launchReviewFlow() }, shape = RoundedCornerShape(10.dp), painter = painterResource(id = R.drawable.ic_skip_to_prev_filled), iconScale = 0.8f, @@ -768,7 +770,7 @@ private fun ConsoleViewModel.Layout( ) NeuButton( - onClick = { skipToNext() }, + onClick = { skipToNext(); activity.launchReviewFlow() }, shape = RoundedCornerShape(10.dp), painter = painterResource(id = R.drawable.ic_skip_to_next_filled), iconScale = 0.8f, diff --git a/app/src/main/java/com/prime/player/audio/library/Library.kt b/app/src/main/java/com/prime/player/audio/library/Library.kt index ff7ccde..59698fd 100644 --- a/app/src/main/java/com/prime/player/audio/library/Library.kt +++ b/app/src/main/java/com/prime/player/audio/library/Library.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -98,7 +99,7 @@ private fun Reel(modifier: Modifier = Modifier) { //Row of icon nad title Row( - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier .padding(horizontal = ContentPadding.normal) .statusBarsPadding2( @@ -113,6 +114,24 @@ private fun Reel(modifier: Modifier = Modifier) { fontWeight = FontWeight.Light ) + Spacer(modifier = Modifier.weight(1f)) + + val billing = LocalBillingManager.current + val isPurchased by billing.isPurchased(id = BillingTokens.DISABLE_ASD_IN_APP_PRODUCT) + val activity = LocalContext.current.activity!! + if (!isPurchased) + IconButton( + painter = painterResource(id = R.drawable.ic_remove_ads), + contentDescription = null, + onClick = { + billing.launchBillingFlow( + activity, + BillingTokens.DISABLE_ASD_IN_APP_PRODUCT + ) + } + ) + + // the icon val navigator = LocalNavController.current IconButton( @@ -136,6 +155,7 @@ private fun Carousal( modifier: Modifier = Modifier ) { val navigator = LocalNavController.current + val activity = LocalContext.current.activity!! Surface( modifier = modifier, elevation = ContentElevation.high, @@ -143,6 +163,7 @@ private fun Carousal( onClick = { val direction = BucketsRoute(Type.ALBUMS.name) navigator.navigateTo(direction) + activity.launchReviewFlow() }, content = { diff --git a/app/src/main/java/com/prime/player/common/billing/Advertiser.kt b/app/src/main/java/com/prime/player/common/billing/Advertiser.kt new file mode 100644 index 0000000..e3472c7 --- /dev/null +++ b/app/src/main/java/com/prime/player/common/billing/Advertiser.kt @@ -0,0 +1,4 @@ +package com.prime.player.common.billing + +interface Advertiser { +} \ No newline at end of file diff --git a/app/src/main/java/com/prime/player/common/billing/BillingManager.kt b/app/src/main/java/com/prime/player/common/billing/BillingManager.kt new file mode 100644 index 0000000..1044036 --- /dev/null +++ b/app/src/main/java/com/prime/player/common/billing/BillingManager.kt @@ -0,0 +1,378 @@ +package com.prime.player.common.billing + +import android.app.Activity +import android.content.Context +import android.util.Log +import androidx.compose.runtime.* +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.android.billingclient.api.* +import com.prime.player.BuildConfig +import kotlinx.coroutines.* +import java.lang.Long.min + +private const val TAG = "BillingManager" + + +private const val RECONNECT_TIMER_START_MILLISECONDS = 1L * 1000L +private const val RECONNECT_TIMER_MAX_TIME_MILLISECONDS = 1000L * 60L * 15L // 15 minutes +private const val SKU_DETAILS_REQUERY_TIME = 1000L * 60L * 60L * 4L // 4 hours + +/** + * A one stop solution for monetization and Billing. + * @param products The list of in-app product ids. + * @param subscriptions The list of in-app subscription Ids. + * + * @author Zakir Ahmad Sheikh + * @since 18-08-2022 + */ +class BillingManager( + context: Context, + products: Array? = null, + // TODO: Implement in future version of BillingManger + subscriptions: Array? = null, +) : PurchasesUpdatedListener, Advertiser, BillingClientStateListener, DefaultLifecycleObserver { + + private val mBillingClient = + BillingClient.newBuilder(context) + .setListener(this) + .enablePendingPurchases() + .build() + + // how long before the data source tries to reconnect to Google play + private var delayReconnectMills = + RECONNECT_TIMER_START_MILLISECONDS + + private val products = + products?.map { product -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(product) + .setProductType(BillingClient.ProductType.INAPP) + .build() + } + + private val subscriptions = + products?.map { product -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(product) + .setProductType(BillingClient.ProductType.SUBS) + .build() + } + + init { + // requires not everything to be null or empty. + require(!products.isNullOrEmpty() || !subscriptions.isNullOrEmpty()) + mBillingClient.startConnection(this) + + } + + /** + * The list of items that the user have purchased. + */ + private val _purchases = + mutableStateOf>(emptyList()) + + /** + * @see _purchases + */ + val purchases: State> get() = _purchases + + /** + * An item is purchased if its state [Purchase.PurchaseState.PURCHASED] && [Purchase.isAcknowledged] + * @param id the product id. + */ + @Composable + fun isPurchased(id: String) = + derivedStateOf { + val purchase = _purchases.value.find { it.products.contains(id) } + purchase != null && purchase.purchaseState == Purchase.PurchaseState.PURCHASED + && if (BuildConfig.DEBUG) true else purchase.isAcknowledged + } + + /** + * The [ProductDetails] mapped with their product Ids. + */ + private val _details = + mutableStateOf>(emptyMap()) + + /** + * @see _details + */ + val details: State> get() = _details + + /** + * A [CoroutineScope] to handle async tasks. + */ + private val billingManagerScope = + CoroutineScope(Dispatchers.IO) + + override fun onPurchasesUpdated( + result: BillingResult, + purchases: MutableList? + ) { + when (result.responseCode) { + BillingClient.BillingResponseCode.OK -> { + if (purchases.isNullOrEmpty()) { + Log.d(TAG, "Null Purchase List Returned from OK response!") + return + } + // process the purchases. + process(purchases) + } + BillingClient.BillingResponseCode.USER_CANCELED -> + Log.i(TAG, "onPurchasesUpdated: User canceled the purchase") + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> + Log.i(TAG, "onPurchasesUpdated: The user already owns this item") + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> Log.e( + TAG, + "onPurchasesUpdated: Developer error means that Google Play " + + "does not recognize the configuration. If you are just getting started, " + + "make sure you have configured the application correctly in the " + + "Google Play Console. The SKU product ID must match and the APK you " + + "are using must be signed with release keys." + ) + else -> + Log.d( + TAG, "BillingResult [" + result.responseCode + "]: " + result.debugMessage + ) + } + } + + /** + * This is a pretty unusual occurrence. It happens primarily if the Google Play Store + * self-upgrades or is force closed. + */ + override fun onBillingServiceDisconnected() = reconnect() + + override fun onBillingSetupFinished(result: BillingResult) { + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.i(TAG, result.debugMessage) + reconnect() + return + } + // The billing client is ready. You can query purchases here. + // This doesn't mean that your app is set up correctly in the console -- it just + // means that you have a connection to the Billing service. + billingManagerScope + .launch { + val purchases = async { query(BillingClient.ProductType.INAPP) } + val response = purchases.await() + // process them. + process(response) + _purchases.value = response + + // details + val details = async { query(products) } + _details.value = details.await().associateBy { it.productId } + } + } + + /** + * It's recommended to requery purchases during onResume. + */ + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + Log.d(TAG, "ON_RESUME") + // this just avoids an extra purchase refresh after we finish a billing flow + if (mBillingClient.isReady) { + billingManagerScope.launch { + // refresh. + val purchases = query(BillingClient.ProductType.INAPP) + process(purchases) + _purchases.value = purchases + } + } + } + + override fun onDestroy(owner: LifecycleOwner) { + Log.i(TAG, "Terminating connection") + mBillingClient.endConnection() + billingManagerScope.cancel("destroying BillingManager") + super.onDestroy(owner) + } + + + /** + * Simple Utility function does is process the [purchases] and acknowledges them + */ + private fun process(purchases: List) { + billingManagerScope.launch { + for (purchase in purchases) { + // Global check to make sure all purchases are signed correctly. + // This check is best performed on your server. + val state = purchase.purchaseState + if (state == Purchase.PurchaseState.PURCHASED) { + if (!isSignatureValid(purchase)) { + Log.e(TAG, "Invalid signature. Make sure your public key is correct.") + continue + } + } + if (!purchase.isAcknowledged) { + acknowledge(purchase) + } + } + } + } + + /** + * A simple query method. + * *Note - Handles all error cases.* + * @param : The list of products to query. + * @return empty in case error else the products. + */ + private suspend fun query( + products: List? + ): List { + + //leave early if null or empty + Log.i(TAG, "query: ") + if (products.isNullOrEmpty()) { + Log.i(TAG, "query: Null $products id list passed.") + return emptyList() + } + + // construct params + val prams = + QueryProductDetailsParams + .newBuilder() + .setProductList(products) + .build() + + val (result, list) = mBillingClient.queryProductDetails(prams) + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.i(TAG, "query: ${result}}") + return emptyList() + } + if (list.isNullOrEmpty()) { + Log.e( + TAG, + "onProductDetailsResponse: " + + "Found null or empty ProductDetails. " + + "Check to see if the Products you requested are correctly " + + "published in the Google Play Console." + ) + return emptyList() + } + return list + } + + /** + * A convince query method. It just returns [_purchases] and handles error cases and nothing more. + * @param type the type of purchases to fetch E.g., [BillingClient.ProductType.INAPP]] + * @return empty list in case of error else purchases. + */ + private suspend fun query( + type: String = BillingClient.ProductType.INAPP + ): List { + if (!mBillingClient.isReady) { + Log.e(TAG, "queryPurchases: BillingClient is not ready") + reconnect() + } + + val params = + QueryPurchasesParams + .newBuilder() + .setProductType(type) + .build() + + val response = mBillingClient.queryPurchasesAsync(params = params) + val result = response.billingResult + + when (result.responseCode) { + BillingClient.BillingResponseCode.OK -> { + if (response.purchasesList.isEmpty()) { + Log.e(TAG, "Null|Empty Purchase List Returned from OK response!") + return emptyList() + } + return response.purchasesList + } + else -> Log.d(TAG, "query [" + result.responseCode + "]: " + result.debugMessage) + } + return emptyList() + } + + + /** + * Ideally your implementation will comprise a secure server, rendering this check + * unnecessary. @see [Security] + */ + private fun isSignatureValid(purchase: Purchase): Boolean = + Security.verifyPurchase(purchase.originalJson, purchase.signature) + + /** + * Acknowledges the [purchase] in case it is not. + * @param purchase the purchase to acknowledge. + * @return true if acknowledged else false. Note if [purchase] is already acknowledged it will return false. + */ + private suspend fun acknowledge( + purchase: Purchase + ): Boolean { + // don't acknowledge in debug app. + if (BuildConfig.DEBUG) return true + if (purchase.isAcknowledged) return false + val params = + AcknowledgePurchaseParams + .newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + val result = mBillingClient.acknowledgePurchase(params) + Log.i(TAG, "acknowledge: $result") + return result.responseCode == BillingClient.BillingResponseCode.OK + && purchase.purchaseState == Purchase.PurchaseState.PURCHASED + + } + + /** + * Retries the billing service connection with exponential backoff, maxing out at the time + * specified by [RECONNECT_TIMER_MAX_TIME_MILLISECONDS]. + */ + private fun reconnect() { + billingManagerScope.launch(Dispatchers.Main) { + val delay = delayReconnectMills + //next delay + delayReconnectMills = + min(delayReconnectMills * 2, RECONNECT_TIMER_MAX_TIME_MILLISECONDS) + delay(delay) + mBillingClient.startConnection(this@BillingManager) + } + } + + /** + * Launch the billing flow. This will launch an external Activity for a result, so it requires + * an Activity reference. For subscriptions, it supports upgrading from one SKU type to another + * by passing in SKUs to be upgraded. + * + * @param activity active activity to launch our billing flow from + * @param sku SKU (Product ID) to be purchased + * @param upgradeSkusVarargs SKUs that the subscription can be upgraded from + * @return true if launch is successful + */ + fun launchBillingFlow( + host: Activity, + product: String, + vararg upgradeSkusVarargs: String + ): Boolean { + val details = _details.value[product] ?: return false + val params = BillingFlowParams + .newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams + .ProductDetailsParams + .newBuilder() + .setProductDetails(details) + .build() + ) + ) + .build() + // val upgradeSkus = arrayOf(*upgradeSkusVarargs) + val result = mBillingClient.launchBillingFlow(host, params) + return when (result.responseCode) { + BillingClient.BillingResponseCode.OK -> true + else -> { + Log.e(TAG, "Billing failed: + " + result.debugMessage) + false + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prime/player/common/billing/Security.java b/app/src/main/java/com/prime/player/common/billing/Security.java new file mode 100644 index 0000000..bfdbc03 --- /dev/null +++ b/app/src/main/java/com/prime/player/common/billing/Security.java @@ -0,0 +1,125 @@ +package com.prime.player.common.billing; + +/* + * This class is an sample of how you can check to make sure your purchases on the device came from + * Google Play. Putting code like this on your server will provide additional protection. + *

+ * One thing that you may also wish to consider doing is caching purchase IDs to make replay attacks + * harder. The reason this code isn't just part of the library is to allow you to customize it (and + * rename it!) to make generic patching exploits more difficult. + */ +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import com.prime.player.BillingTokens; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +/** + * Security-related methods. For a secure implementation, all of this code should be implemented on + * a server that communicates with the application on the device. + */ +class Security { + static final private String TAG = "IABUtil/Security"; + static final private String KEY_FACTORY_ALGORITHM = "RSA"; + static final private String SIGNATURE_ALGORITHM = "SHA1withRSA"; + + /** + * BASE_64_ENCODED_PUBLIC_KEY should be YOUR APPLICATION PUBLIC KEY. You currently get this + * from the Google Play developer console under the "Monetization Setup" category in the + * Licensing area. This build has been setup so that if you define base64EncodedPublicKey in + * your local.properties, it will be echoed into BuildConfig. + */ + + final private static String BASE_64_ENCODED_PUBLIC_KEY = BillingTokens.PUBLIC_KEY; + + /** + * Verifies that the data was signed with the given signature + * + * @param signedData the signed JSON string (signed, not encrypted) + * @param signature the signature for the data, signed with the private key + */ + static public boolean verifyPurchase(String signedData, String signature) { + if ((TextUtils.isEmpty(signedData) || TextUtils.isEmpty(BASE_64_ENCODED_PUBLIC_KEY) + || TextUtils.isEmpty(signature)) + ) { + Log.w(TAG, "Purchase verification failed: missing data."); + return false; + } + try { + PublicKey key = generatePublicKey(BASE_64_ENCODED_PUBLIC_KEY); + return verify(key, signedData, signature); + } catch (IOException e) { + Log.e(TAG, "Error generating PublicKey from encoded key: " + e.getMessage()); + return false; + } + } + + /** + * Generates a PublicKey instance from a string containing the Base64-encoded public key. + * + * @param encodedPublicKey Base64-encoded public key + * @throws IOException if encoding algorithm is not supported or key specification + * is invalid + */ + static private PublicKey generatePublicKey(String encodedPublicKey) throws IOException { + try { + byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM); + return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey)); + } catch (NoSuchAlgorithmException e) { + // "RSA" is guaranteed to be available. + throw new RuntimeException(e); + } catch (InvalidKeySpecException e) { + String msg = "Invalid key specification: " + e; + Log.w(TAG, msg); + throw new IOException(msg); + } + } + + /** + * Verifies that the signature from the server matches the computed signature on the data. + * Returns true if the data is correctly signed. + * + * @param publicKey public key associated with the developer account + * @param signedData signed data from server + * @param signature server signature + * @return true if the data and signature match + */ + static private Boolean verify(PublicKey publicKey, String signedData, String signature) { + byte[] signatureBytes; + try { + signatureBytes = Base64.decode(signature, Base64.DEFAULT); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Base64 decoding failed."); + return false; + } + try { + Signature signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM); + signatureAlgorithm.initVerify(publicKey); + signatureAlgorithm.update(signedData.getBytes()); + if (!signatureAlgorithm.verify(signatureBytes)) { + Log.w(TAG, "Signature verification failed..."); + return false; + } + return true; + } catch (NoSuchAlgorithmException e) { + // "RSA" is guaranteed to be available. + throw new RuntimeException(e); + } catch (InvalidKeyException e) { + Log.e(TAG, "Invalid key specification."); + } catch (SignatureException e) { + Log.e(TAG, "Signature exception."); + } + return false; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prime/player/common/compose/More.kt b/app/src/main/java/com/prime/player/common/compose/More.kt index ae3bfaa..f08142f 100644 --- a/app/src/main/java/com/prime/player/common/compose/More.kt +++ b/app/src/main/java/com/prime/player/common/compose/More.kt @@ -24,6 +24,7 @@ import com.google.accompanist.systemuicontroller.SystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.review.ReviewManager +import com.prime.player.common.billing.BillingManager import com.primex.core.Text import com.primex.core.resolve import com.primex.core.spannedResource @@ -224,23 +225,11 @@ inline fun Resources.stringResource(res: Text) = inline fun Resources.stringResource(res: Text?) = resolve(res) -/** - * Convince method to pass the AppUpdateManager down the compose tree. - */ -val LocalAppUpdateManager = - compositionLocalOf { - error("No Local App Update Manager set.") - } - -/** - * Convince way to pass the ReviewManager down the compose tree. - */ -val LocalAppReviewManager = - compositionLocalOf { - error("No Local Review Manager set. ") - } - val LocalSystemUiController = staticCompositionLocalOf { error("No ui controller defined!!") } +val LocalBillingManager = + compositionLocalOf { + error("No Local BillingClient set. ") + } \ No newline at end of file diff --git a/app/src/main/java/com/prime/player/core/LocalDb.kt b/app/src/main/java/com/prime/player/core/LocalDb.kt index b68b039..471befb 100644 --- a/app/src/main/java/com/prime/player/core/LocalDb.kt +++ b/app/src/main/java/com/prime/player/core/LocalDb.kt @@ -455,8 +455,11 @@ interface Members { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(members: List): List - @Update - suspend fun update(member: Playlist.Member): Int + /** + * This is not the recommanded way to do it. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun update(member: Playlist.Member): Long @Delete suspend fun delete(member: Playlist.Member): Int diff --git a/app/src/main/java/com/prime/player/core/Repository.kt b/app/src/main/java/com/prime/player/core/Repository.kt index 0c2b16e..8a7a8c9 100644 --- a/app/src/main/java/com/prime/player/core/Repository.kt +++ b/app/src/main/java/com/prime/player/core/Repository.kt @@ -299,8 +299,8 @@ class Repository @Inject constructor( true -> { //localDb.members.update(member.copy(order = 0)) // updating the member doesn't work as expected. - localDb.members.delete(member) - localDb.members.insert(member = member.copy(order = 0)) + // localDb.members.delete(member) + localDb.members.update(member = member.copy(order = 0)) } else -> { // check the limit in this case diff --git a/app/src/main/java/com/prime/player/settings/GlobalKeys.kt b/app/src/main/java/com/prime/player/settings/GlobalKeys.kt index ff62883..7888a2e 100644 --- a/app/src/main/java/com/prime/player/settings/GlobalKeys.kt +++ b/app/src/main/java/com/prime/player/settings/GlobalKeys.kt @@ -58,9 +58,9 @@ object GlobalKeys { val FONT_SCALE = floatPreferenceKey( - TAG + "_font_scale", - defaultValue = 1.0f - ) + TAG + "_font_scale", + defaultValue = 1.0f + ) private val defaultMinTrackLimit = TimeUnit.MINUTES.toMillis(1) @@ -81,5 +81,9 @@ object GlobalKeys { defaultValue = 20 ) - + /** + * The counter counts the number of times this app was launched. + */ + val KEY_LAUNCH_COUNTER = + intPreferenceKey(TAG + "_launch_counter") } \ No newline at end of file diff --git a/app/src/main/java/com/prime/player/settings/Settings.kt b/app/src/main/java/com/prime/player/settings/Settings.kt index 8902e1a..3a75b7e 100644 --- a/app/src/main/java/com/prime/player/settings/Settings.kt +++ b/app/src/main/java/com/prime/player/settings/Settings.kt @@ -263,7 +263,6 @@ fun Settings( // The app versiona and check for updates. val version = BuildConfig.VERSION_NAME - val updateManager = LocalAppUpdateManager.current val channel = LocalSnackDataChannel.current val activity = context.activity!! Preference( @@ -272,13 +271,7 @@ fun Settings( icon = Icons.Outlined.TouchApp, modifier = Modifier.clickable( onClick = { - viewModel.viewModelScope.launch { - updateManager.check( - channel = channel, - activity = activity, - report = true - ) - } + activity.launchUpdateFlow(channel) } ) ) diff --git a/app/src/main/res/drawable/ic_remove_ads.xml b/app/src/main/res/drawable/ic_remove_ads.xml new file mode 100644 index 0000000..f4f1915 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove_ads.xml @@ -0,0 +1,4 @@ + + +