diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8ec1aa7156..ec636eec2f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,7 +48,7 @@ val getGitHash = providers val firebaseEnabledVariants = listOf("play", "fdroid") val nonPlayVariants = listOf("fdroid", "website") + if (huaweiEnabled) listOf("huawei") else emptyList() -val nonDebugBuildTypes = listOf("release", "qa", "automaticQa") +val nonDebugBuildTypes = listOf("release", "releaseWithDebugMenu", "qa", "automaticQa") fun VariantDimension.devNetDefaultOn(defaultOn: Boolean) { val fqEnumClass = "org.session.libsession.utilities.Environment" @@ -169,6 +169,12 @@ android { setAuthorityPostfix("") } + create("releaseWithDebugMenu") { + initWith(getByName("release")) + + matchingFallbacks += "release" + } + create("qa") { initWith(getByName("release")) @@ -473,6 +479,9 @@ dependencies { implementation(libs.androidx.biometric) + playImplementation(libs.android.billing) + playImplementation(libs.android.billing.ktx) + debugImplementation(libs.sqlite.web.viewer) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 1b436e416b..e3ed976a0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -235,6 +235,20 @@ fun DebugMenu( "Session Pro", verticalArrangement = Arrangement.spacedBy(0.dp)) { Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Text(text = "Purchase a plan") + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + DropDown( + selected = null, + modifier = modifier, + values = uiState.debugProPlans, + onValueSelected = { sendCommand(DebugMenuViewModel.Commands.PurchaseDebugPlan(it)) }, + labeler = { it?.label ?: "Select a plan to buy" } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( text = "Set current user as Pro", checked = uiState.forceCurrentUserAsPro, @@ -735,7 +749,8 @@ fun PreviewDebugMenu() { messageProFeature = setOf(ProStatusManager.MessageProFeature.AnimatedAvatar), dbInspectorState = DebugMenuViewModel.DatabaseInspectorState.STARTED, debugSubscriptionStatuses = setOf(DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE), - selectedDebugSubscriptionStatus = DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE + selectedDebugSubscriptionStatus = DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE, + debugProPlans = emptyList(), ), sendCommand = {}, onClose = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index a637fdf270..504ce8609c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager import org.thoughtcrime.securesms.util.ClearDataUtils @@ -58,6 +59,7 @@ class DebugMenuViewModel @Inject constructor( private val attachmentDatabase: AttachmentDatabase, private val conversationRepository: ConversationRepository, private val databaseInspector: DatabaseInspector, + subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ) : ViewModel() { private val TAG = "DebugMenu" @@ -90,6 +92,9 @@ class DebugMenuViewModel @Inject constructor( DebugSubscriptionStatus.EXPIRED, ), selectedDebugSubscriptionStatus = textSecurePreferences.getDebugSubscriptionType() ?: DebugSubscriptionStatus.AUTO_GOOGLE, + debugProPlans = subscriptionManagers.asSequence() + .flatMap { it.availablePlans.asSequence().map { plan -> DebugProPlan(it, plan) } } + .toList(), ) ) val uiState: StateFlow @@ -295,6 +300,10 @@ class DebugMenuViewModel @Inject constructor( it.copy(selectedDebugSubscriptionStatus = command.status) } } + + is Commands.PurchaseDebugPlan -> { + command.plan.apply { manager.purchasePlan(plan) } + } } } @@ -400,6 +409,7 @@ class DebugMenuViewModel @Inject constructor( val dbInspectorState: DatabaseInspectorState, val debugSubscriptionStatuses: Set, val selectedDebugSubscriptionStatus: DebugSubscriptionStatus, + val debugProPlans: List, ) enum class DatabaseInspectorState { @@ -440,5 +450,6 @@ class DebugMenuViewModel @Inject constructor( data class GenerateContacts(val prefix: String, val count: Int): Commands() data object ToggleDatabaseInspector : Commands() data class SetDebugSubscriptionStatus(val status: DebugSubscriptionStatus) : Commands() + data class PurchaseDebugPlan(val plan: DebugProPlan) : Commands() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugProPlan.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugProPlan.kt new file mode 100644 index 0000000000..94a8aa4e71 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugProPlan.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.debugmenu + +import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager + +data class DebugProPlan( + val manager: SubscriptionManager, + val plan: ProSubscriptionDuration +) { + val label: String get() = "${manager.id}-${plan.name}" +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt index 8bb492053e..e1730e784e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.notifications.BackgroundPollManager import org.thoughtcrime.securesms.notifications.PushRegistrationHandler import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionCoordinator +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.service.ExpiringMessageManager import org.thoughtcrime.securesms.tokenpage.TokenDataManager import org.thoughtcrime.securesms.util.AppVisibilityManager @@ -68,6 +69,7 @@ class OnAppStartupComponents private constructor( subscriptionCoordinator: SubscriptionCoordinator, avatarUploadManager: AvatarUploadManager, configToDatabaseSync: ConfigToDatabaseSync, + subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ): this( components = listOf( configUploader, @@ -99,6 +101,6 @@ class OnAppStartupComponents private constructor( subscriptionCoordinator, avatarUploadManager, configToDatabaseSync, - ) + ) + subscriptionManagers ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 7cd84e5d79..566e48365b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -12,6 +12,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val iconRes = null override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} + override val availablePlans: List + get() = emptyList() //todo PRO test out build type with no subscription providers available - What do we show on the Pro Settings page? } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index a6849a3c17..ee22e8d082 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -1,13 +1,17 @@ package org.thoughtcrime.securesms.pro.subscription +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent + /** * Represents the implementation details of a given subscription provider */ -interface SubscriptionManager { +interface SubscriptionManager: OnAppStartupComponent { val id: String val displayName: String val description: String val iconRes: Int? + val availablePlans: List + fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt index f2608d7cb5..01832b6438 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt @@ -23,13 +23,30 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.bold -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DropDown( modifier: Modifier = Modifier, selectedText: String, values: List, onValueSelected: (String) -> Unit +) { + DropDown( + modifier = modifier, + selected = selectedText, + values = values, + onValueSelected = onValueSelected, + labeler = { it.orEmpty() } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropDown( + modifier: Modifier = Modifier, + selected: T?, + values: List, + onValueSelected: (T) -> Unit, + labeler: (T?) -> String, ) { var expanded by remember { mutableStateOf(false) } @@ -41,7 +58,7 @@ fun DropDown( } ) { TextField( - value = selectedText, + value = labeler(selected), onValueChange = {}, readOnly = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, @@ -80,7 +97,7 @@ fun DropDown( DropdownMenuItem( text = { Text( - text = item, + text = labeler(item), style = LocalType.current.base ) }, @@ -97,6 +114,7 @@ fun DropDown( } } + @Preview @Composable fun PreviewDropDown() { diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index a47dd2309f..4b39a5bd6f 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -1,17 +1,141 @@ package org.thoughtcrime.securesms.pro.subscription +import android.app.Application +import android.widget.Toast +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.queryProductDetails +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.util.CurrentActivityObserver import javax.inject.Inject /** * The Google Play Store implementation of our subscription manager */ -class PlayStoreSubscriptionManager @Inject constructor(): SubscriptionManager { +class PlayStoreSubscriptionManager @Inject constructor( + private val application: Application, + @param:ManagerScope private val scope: CoroutineScope, + private val currentActivityObserver: CurrentActivityObserver, +) : SubscriptionManager { override val id = "google_play_store" override val displayName = "" override val description = "" override val iconRes = null + private val billingClient by lazy { + BillingClient.newBuilder(application) + .setListener { result, purchases -> + Log.d(TAG, "onPurchasesUpdated: $result, $purchases") + } + .enableAutoServiceReconnection() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .enablePrepaidPlans() + .build() + ) + .build() + } + + override val availablePlans: List = + ProSubscriptionDuration.entries.toList() + override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) { - //todo PRO implement + scope.launch { + try { + val activity = checkNotNull(currentActivityObserver.currentActivity.value) { + "No current activity available to launch the billing flow" + } + + val result = billingClient.queryProductDetails( + QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("session_pro") + .setProductType(BillingClient.ProductType.SUBS) + .build() + ) + ) + .build() + ) + + check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result.billingResult}" + } + + val productDetails = checkNotNull(result.productDetailsList?.firstOrNull()) { + "Unable to get the product: product for given id is null" + } + + val planId = subscriptionDuration.planId + + val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails + ?.firstOrNull { it.basePlanId == planId }) { + "Unable to find a plan with id $planId" + } + + val billingResult = billingClient.launchBillingFlow( + activity, BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerDetails.offerToken) + .build() + ) + ) + .build() + ) + + check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" + } + + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error purchase plan", e) + + withContext(Dispatchers.Main) { + Toast.makeText(application, e.message, Toast.LENGTH_LONG).show() + } + } + } + } + + private val ProSubscriptionDuration.planId: String + get() = when (this) { + ProSubscriptionDuration.ONE_MONTH -> "session-pro-1-month" + ProSubscriptionDuration.THREE_MONTHS -> "session-pro-3-months" + ProSubscriptionDuration.TWELVE_MONTHS -> "session-pro-12-months" + } + + override fun onPostAppStarted() { + super.onPostAppStarted() + + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingServiceDisconnected() { + Log.w(TAG, "onBillingServiceDisconnected") + } + + override fun onBillingSetupFinished(result: BillingResult) { + Log.d(TAG, "onBillingSetupFinished with $result") + } + }) + } + + companion object { + private const val TAG = "PlayStoreSubscriptionManager" } } \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlaySubscriptionModule.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlaySubscriptionModule.kt index 36d2dc786d..23fddf143b 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlaySubscriptionModule.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlaySubscriptionModule.kt @@ -5,7 +5,6 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -14,4 +13,5 @@ abstract class PlaySubscriptionModule { @Binds @IntoSet abstract fun providePlayStoreManager(manager: PlayStoreSubscriptionManager): SubscriptionManager + } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c032e526d3..e190fd1e77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,7 @@ zxingVersion = "3.5.3" huaweiPushVersion = "6.13.0.300" googlePlayReviewVersion = "2.0.2" coilVersion = "3.3.0" +billingVersion = "8.0.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissionsVersion" } @@ -171,6 +172,8 @@ sqlite-web-viewer = { module = "io.github.simophin:sqlite-web-viewer", version = protoc = { module = "com.google.protobuf:protoc", version = "4.31.1" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilVersion" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilVersion" } +android-billing = { module = "com.android.billingclient:billing", version.ref = "billingVersion" } +android-billing-ktx = { module = "com.android.billingclient:billing-ktx", version.ref = "billingVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "gradlePluginVersion" } diff --git a/scripts/build-and-release.py b/scripts/build-and-release.py index 7fb34fdfbf..827f810126 100755 --- a/scripts/build-and-release.py +++ b/scripts/build-and-release.py @@ -46,7 +46,7 @@ def __init__(self, credentials: dict): self.key_alias = credentials['key_alias'] self.key_password = credentials['key_password'] -def build_releases(project_root: str, flavor: str, credentials_property_prefix: str, credentials: BuildCredentials, huawei: bool=False) -> BuildResult: +def build_releases(project_root: str, flavor: str, credentials_property_prefix: str, credentials: BuildCredentials, huawei: bool=False, build_type: string = 'release') -> BuildResult: (keystore_fd, keystore_file) = tempfile.mkstemp(prefix='keystore_', suffix='.jks', dir=build_dir) try: with os.fdopen(keystore_fd, 'wb') as f: @@ -62,10 +62,10 @@ def build_releases(project_root: str, flavor: str, credentials_property_prefix: gradle_commands += ' -Phuawei ' subprocess.run(f"""{gradle_commands} \ - assemble{flavor.capitalize()}Release \ - bundle{flavor.capitalize()}Release --stacktrace""", shell=True, check=True, cwd=project_root) + assemble{flavor.capitalize()}{build_type.capitalize()} \ + bundle{flavor.capitalize()}{build_type.capitalize()} --stacktrace""", shell=True, check=True, cwd=project_root) - apk_output_dir = os.path.join(project_root, f'app/build/outputs/apk/{flavor}/release') + apk_output_dir = os.path.join(project_root, f'app/build/outputs/apk/{flavor}/{build_type}') with open(os.path.join(apk_output_dir, 'output-metadata.json')) as f: play_outputs = json.load(f) @@ -81,7 +81,7 @@ def build_releases(project_root: str, flavor: str, credentials_property_prefix: apk_paths=apks, package_id=package_id, version_name=version_name, - bundle_path=os.path.join(project_root, f'app/build/outputs/bundle/{flavor}Release/app-{flavor}-release.aab')) + bundle_path=os.path.join(project_root, f'app/build/outputs/bundle/{flavor}{build_type.capitalize()}/app-{flavor}-{build_type}.aab')) finally: print(f'Cleaning up keystore file: {keystore_file}') @@ -225,6 +225,7 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent ) parser.add_argument('--build-play-only', action='store_true', help='If set, will only build Play releases and skip F-Droid and Huawei releases.') +parser.add_argument('--build-type', help='Build with specified build type. Default: release', default = 'release') args = parser.parse_args() @@ -255,7 +256,8 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent project_root=project_root, flavor='play', credentials=BuildCredentials(credentials['build']['play']), - credentials_property_prefix='SESSION' + credentials_property_prefix='SESSION', + build_type=args.build_type, ) if args.build_play_only: @@ -263,12 +265,12 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent sys.exit(0) print("Building fdroid releases...") - fdroid_build_result = build_releases( project_root=project_root, flavor='fdroid', credentials=BuildCredentials(credentials['build']['play']), - credentials_property_prefix='SESSION' + credentials_property_prefix='SESSION', + build_type=args.build_type, ) print("Updating fdroid repo...") @@ -280,7 +282,8 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent flavor='huawei', credentials=BuildCredentials(credentials['build']['huawei']), credentials_property_prefix='SESSION_HUAWEI', - huawei=True + huawei=True, + build_type=args.build_type ) # If the a github release draft exists, upload the apks to the release