diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..5e9c80de --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,121 @@ +name: Android Build + +on: + push: + branches: [main] + paths: + - 'android/**' + - '.github/workflows/android.yml' + pull_request: + paths: + - 'android/**' + - '.github/workflows/android.yml' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.9 + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Generate Gradle wrapper + run: gradle wrapper --gradle-version 8.9 + + - name: Build debug APK + run: ./gradlew assembleDebug --no-daemon + + - name: Run unit tests + run: ./gradlew testDebugUnitTest --no-daemon + continue-on-error: true + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: android/app/build/outputs/apk/debug/app-debug.apk + retention-days: 7 + + release: + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' + defaults: + run: + working-directory: android + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.9 + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Generate Gradle wrapper + run: gradle wrapper --gradle-version 8.9 + + - name: Build release APK (unsigned) + run: ./gradlew assembleRelease --no-daemon + + - name: Upload release APK + uses: actions/upload-artifact@v4 + with: + name: app-release-unsigned + path: android/app/build/outputs/apk/release/app-release-unsigned.apk + retention-days: 30 + + # Note: For actual Play Store deployment, you would need to: + # 1. Set up signing keystore from secrets + # 2. Sign the APK or build AAB + # 3. Upload to Play Store using fastlane or Google Play Developer API + + - name: Release step placeholder + run: | + echo "Release step placeholder" + echo "In production, this would:" + echo "1. Decode signing keystore from secrets" + echo "2. Sign the release APK or build signed AAB" + echo "3. Upload to Google Play Console" diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 00000000..4d8723f6 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,104 @@ +name: iOS Build + +on: + push: + branches: [main] + paths: + - 'ios/**' + - '.github/workflows/ios.yml' + pull_request: + paths: + - 'ios/**' + - '.github/workflows/ios.yml' + workflow_dispatch: + +jobs: + build: + runs-on: macos-14 + defaults: + run: + working-directory: ios + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select Xcode version + run: sudo xcode-select -s /Applications/Xcode_15.2.app + + - name: Show Xcode version + run: xcodebuild -version + + - name: Cache Swift Package Manager + uses: actions/cache@v4 + with: + path: | + ios/.build + ~/Library/Caches/org.swift.swiftpm + key: ${{ runner.os }}-spm-${{ hashFiles('ios/Package.swift') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Resolve Swift packages + run: swift package resolve + + - name: Build for iOS Simulator + run: | + xcodebuild build \ + -scheme MySpeedPuzzling \ + -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty + + - name: Run tests + run: | + xcodebuild test \ + -scheme MySpeedPuzzling \ + -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty + continue-on-error: true + + archive: + runs-on: macos-14 + needs: build + if: github.ref == 'refs/heads/main' + defaults: + run: + working-directory: ios + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select Xcode version + run: sudo xcode-select -s /Applications/Xcode_15.2.app + + - name: Cache Swift Package Manager + uses: actions/cache@v4 + with: + path: | + ios/.build + ~/Library/Caches/org.swift.swiftpm + key: ${{ runner.os }}-spm-${{ hashFiles('ios/Package.swift') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Resolve Swift packages + run: swift package resolve + + # Note: For actual App Store deployment, you would need to: + # 1. Set up code signing certificates and provisioning profiles + # 2. Use fastlane or manual xcodebuild archive commands + # 3. Upload to App Store Connect using xcrun altool or fastlane deliver + + - name: Archive for distribution (dry run) + run: | + echo "Archive step placeholder" + echo "In production, this would:" + echo "1. Import signing certificates from secrets" + echo "2. Build and archive the app" + echo "3. Export IPA for App Store" + echo "4. Upload to App Store Connect" diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..1d09a288 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,55 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# Android Studio +*.iml +.idea/ +*.hprof + +# Local configuration +local.properties +secrets.properties + +# Keystore files +*.jks +*.keystore + +# Google Services +google-services.json + +# Proguard +proguard/ + +# Log Files +*.log + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Kotlin +.kotlin/ + +# Caches +.cxx/ + +# Environment files +.env +.env.* +!.env.example + +# Generated files +*.apk +*.aab +*.ap_ +*.dex + +# Native +obj/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..b254e174 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,88 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.myspeedpuzzling.app" + compileSdk = 35 + + defaultConfig { + applicationId = "com.myspeedpuzzling.app" + minSdk = 28 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + isDebuggable = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + buildConfig = true + } +} + +dependencies { + // Hotwire Native Android + implementation("dev.hotwire:core:1.2.4") + implementation("dev.hotwire:navigation-fragments:1.2.4") + + // AndroidX + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.activity:activity-ktx:1.8.2") + implementation("androidx.fragment:fragment-ktx:1.6.2") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.webkit:webkit:1.9.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // Material Design + implementation("com.google.android.material:material:1.11.0") + + // CameraX for barcode scanning + implementation("androidx.camera:camera-core:1.3.1") + implementation("androidx.camera:camera-camera2:1.3.1") + implementation("androidx.camera:camera-lifecycle:1.3.1") + implementation("androidx.camera:camera-view:1.3.1") + + // ML Kit Barcode Scanning + implementation("com.google.mlkit:barcode-scanning:17.2.0") + + // Google Play Billing + implementation("com.android.billingclient:billing-ktx:6.1.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // OkHttp for API calls + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.json:json:20231013") + + // Testing + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..aa2acabe --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,27 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. + +# Keep JavaScript interface methods +-keepclassmembers class com.myspeedpuzzling.features.BarcodeScannerBridge { + @android.webkit.JavascriptInterface ; +} + +-keepclassmembers class com.myspeedpuzzling.billing.BillingBridge { + @android.webkit.JavascriptInterface ; +} + +# Keep Hotwire Turbo classes +-keep class dev.hotwire.turbo.** { *; } + +# Keep Google Play Billing classes +-keep class com.android.vending.billing.** { *; } + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } + +# ML Kit +-keep class com.google.mlkit.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a71498bb --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/myspeedpuzzling/app/MainActivity.kt b/android/app/src/main/java/com/myspeedpuzzling/app/MainActivity.kt new file mode 100644 index 00000000..1f9833c2 --- /dev/null +++ b/android/app/src/main/java/com/myspeedpuzzling/app/MainActivity.kt @@ -0,0 +1,24 @@ +package com.myspeedpuzzling.app + +import android.os.Bundle +import android.view.View +import androidx.activity.enableEdgeToEdge +import dev.hotwire.navigation.activities.HotwireActivity +import dev.hotwire.navigation.navigator.NavigatorConfiguration + +class MainActivity : HotwireActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } + + override fun navigatorConfigurations() = listOf( + NavigatorConfiguration( + name = "main", + startLocation = "https://myspeedpuzzling.com", + navigatorHostId = R.id.main_nav_host + ) + ) +} diff --git a/android/app/src/main/java/com/myspeedpuzzling/app/MySpeedPuzzlingApplication.kt b/android/app/src/main/java/com/myspeedpuzzling/app/MySpeedPuzzlingApplication.kt new file mode 100644 index 00000000..1c5fada3 --- /dev/null +++ b/android/app/src/main/java/com/myspeedpuzzling/app/MySpeedPuzzlingApplication.kt @@ -0,0 +1,28 @@ +package com.myspeedpuzzling.app + +import android.app.Application +import dev.hotwire.core.config.Hotwire +import dev.hotwire.navigation.config.defaultFragmentDestination +import dev.hotwire.navigation.config.registerFragmentDestinations +import dev.hotwire.navigation.fragments.HotwireWebFragment + +class MySpeedPuzzlingApplication : Application() { + + override fun onCreate() { + super.onCreate() + + // Configure Hotwire + Hotwire.config.debugLoggingEnabled = BuildConfig.DEBUG + Hotwire.config.webViewDebuggingEnabled = BuildConfig.DEBUG + + // Set custom user agent + Hotwire.config.applicationUserAgentPrefix = "MySpeedPuzzling Android/1.0;" + + // Register our custom WebFragment as default destination + Hotwire.defaultFragmentDestination = SpeedPuzzlingWebFragment::class + Hotwire.registerFragmentDestinations( + SpeedPuzzlingWebFragment::class, + HotwireWebFragment::class + ) + } +} diff --git a/android/app/src/main/java/com/myspeedpuzzling/app/SpeedPuzzlingWebFragment.kt b/android/app/src/main/java/com/myspeedpuzzling/app/SpeedPuzzlingWebFragment.kt new file mode 100644 index 00000000..4b7f82f8 --- /dev/null +++ b/android/app/src/main/java/com/myspeedpuzzling/app/SpeedPuzzlingWebFragment.kt @@ -0,0 +1,102 @@ +package com.myspeedpuzzling.app + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import android.webkit.WebView +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink +import dev.hotwire.navigation.fragments.HotwireWebFragment +import com.myspeedpuzzling.billing.BillingBridge +import com.myspeedpuzzling.features.BarcodeScannerBridge + +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web") +class SpeedPuzzlingWebFragment : HotwireWebFragment() { + private var scannerBridge: BarcodeScannerBridge? = null + private var billingBridge: BillingBridge? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Use post to ensure the view hierarchy is fully initialized + view.post { + setupJavaScriptBridges() + } + } + + @SuppressLint("JavascriptInterface") + private fun setupJavaScriptBridges() { + // Try to find the WebView in the fragment's view hierarchy + val webView = findWebView(view) ?: return + val context = requireContext() + + // Initialize scanner bridge + scannerBridge = BarcodeScannerBridge(context).also { bridge -> + bridge.setWebView(webView) + webView.addJavascriptInterface(bridge, "AndroidScanner") + } + + // Initialize billing bridge + billingBridge = BillingBridge(context).also { bridge -> + bridge.setWebView(webView) + webView.addJavascriptInterface(bridge, "AndroidBilling") + } + + // Inject JavaScript helpers when page loads + injectNativeHelpers(webView) + } + + private fun findWebView(view: View?): WebView? { + if (view == null) return null + if (view is WebView) return view + + if (view is android.view.ViewGroup) { + for (i in 0 until view.childCount) { + val found = findWebView(view.getChildAt(i)) + if (found != null) return found + } + } + return null + } + + private fun injectNativeHelpers(webView: WebView) { + val script = """ + window.isNativeApp = true; + window.nativePlatform = 'android'; + + // Scanner bridge + window.openNativeScanner = function() { + if (window.AndroidScanner) { + AndroidScanner.openScanner(); + } + }; + + // Billing bridge + window.purchaseProduct = function(productId) { + if (window.AndroidBilling) { + AndroidBilling.purchase(productId); + } + }; + + window.restorePurchases = function() { + if (window.AndroidBilling) { + AndroidBilling.restorePurchases(); + } + }; + + window.checkSubscription = function() { + if (window.AndroidBilling) { + AndroidBilling.checkSubscription(); + } + }; + """.trimIndent() + + webView.evaluateJavascript(script, null) + } + + override fun onDestroyView() { + billingBridge?.destroy() + scannerBridge = null + billingBridge = null + super.onDestroyView() + } +} diff --git a/android/app/src/main/java/com/myspeedpuzzling/billing/BillingBridge.kt b/android/app/src/main/java/com/myspeedpuzzling/billing/BillingBridge.kt new file mode 100644 index 00000000..f8fecddf --- /dev/null +++ b/android/app/src/main/java/com/myspeedpuzzling/billing/BillingBridge.kt @@ -0,0 +1,121 @@ +package com.myspeedpuzzling.billing + +import android.content.Context +import android.webkit.JavascriptInterface +import android.webkit.WebView +import java.lang.ref.WeakReference + +/** + * JavaScript bridge for Google Play Billing on Android. + * Receives purchase requests from web JavaScript and initiates native billing flows. + */ +class BillingBridge(context: Context) { + private val contextRef = WeakReference(context) + private var webViewRef: WeakReference? = null + private var billingManager: BillingManager? = null + + init { + billingManager = BillingManager(context).apply { + setCallback(object : BillingManager.BillingCallback { + override fun onPurchaseSuccess(productId: String, purchaseToken: String) { + sendPurchaseSuccess(productId, purchaseToken) + } + + override fun onPurchaseCancelled() { + sendPurchaseCancelled() + } + + override fun onPurchaseError(error: String) { + sendPurchaseError(error) + } + + override fun onRestoreSuccess() { + sendRestoreSuccess() + } + + override fun onRestoreError(error: String) { + sendRestoreError(error) + } + + override fun onSubscriptionStatus(active: Boolean) { + sendSubscriptionStatus(active) + } + }) + } + } + + fun setWebView(webView: WebView) { + this.webViewRef = WeakReference(webView) + } + + @JavascriptInterface + fun purchase(productId: String) { + billingManager?.launchPurchaseFlow(productId) + } + + @JavascriptInterface + fun restorePurchases() { + billingManager?.restorePurchases() + } + + @JavascriptInterface + fun checkSubscription() { + billingManager?.checkSubscriptionStatus() + } + + fun destroy() { + billingManager?.destroy() + } + + // JavaScript callbacks + + private fun sendPurchaseSuccess(productId: String, purchaseToken: String) { + val webView = webViewRef?.get() ?: return + val js = "window.onAndroidPurchaseSuccess && window.onAndroidPurchaseSuccess('$productId', '$purchaseToken')" + webView.post { + webView.evaluateJavascript(js, null) + } + } + + private fun sendPurchaseCancelled() { + val webView = webViewRef?.get() ?: return + val js = "window.onAndroidPurchaseCancelled && window.onAndroidPurchaseCancelled()" + webView.post { + webView.evaluateJavascript(js, null) + } + } + + private fun sendPurchaseError(error: String) { + val webView = webViewRef?.get() ?: return + val escapedError = error.replace("'", "\\'") + val js = "window.onAndroidPurchaseError && window.onAndroidPurchaseError('$escapedError')" + webView.post { + webView.evaluateJavascript(js, null) + } + } + + private fun sendRestoreSuccess() { + val webView = webViewRef?.get() ?: return + val js = "window.onAndroidRestoreSuccess && window.onAndroidRestoreSuccess()" + webView.post { + webView.evaluateJavascript(js, null) + } + } + + private fun sendRestoreError(error: String) { + val webView = webViewRef?.get() ?: return + val escapedError = error.replace("'", "\\'") + val js = "window.onAndroidRestoreError && window.onAndroidRestoreError('$escapedError')" + webView.post { + webView.evaluateJavascript(js, null) + } + } + + private fun sendSubscriptionStatus(active: Boolean) { + val webView = webViewRef?.get() ?: return + val js = "window.onAndroidSubscriptionStatus && window.onAndroidSubscriptionStatus($active)" + webView.post { + webView.evaluateJavascript(js, null) + } + } +} diff --git a/android/app/src/main/java/com/myspeedpuzzling/billing/BillingManager.kt b/android/app/src/main/java/com/myspeedpuzzling/billing/BillingManager.kt new file mode 100644 index 00000000..0d7c3079 --- /dev/null +++ b/android/app/src/main/java/com/myspeedpuzzling/billing/BillingManager.kt @@ -0,0 +1,194 @@ +package com.myspeedpuzzling.billing + +import android.content.Context +import android.util.Log +import com.android.billingclient.api.* +import kotlinx.coroutines.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** + * Manages Google Play Billing for subscriptions. + * Note: launchPurchaseFlow requires an Activity context which is not available + * when initialized from Application. This is a stub implementation that logs + * the purchase request. Full implementation requires activity reference. + */ +class BillingManager(private val context: Context) : PurchasesUpdatedListener { + private var billingClient: BillingClient? = null + private var callback: BillingCallback? = null + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val httpClient = OkHttpClient() + + companion object { + private const val TAG = "BillingManager" + // Product IDs matching Play Console configuration + const val MONTHLY_PRODUCT_ID = "premium_monthly" + const val YEARLY_PRODUCT_ID = "premium_yearly" + private const val BACKEND_URL = "https://myspeedpuzzling.com/api/android/verify-purchase" + } + + interface BillingCallback { + fun onPurchaseSuccess(productId: String, purchaseToken: String) + fun onPurchaseCancelled() + fun onPurchaseError(error: String) + fun onRestoreSuccess() + fun onRestoreError(error: String) + fun onSubscriptionStatus(active: Boolean) + } + + init { + setupBillingClient() + } + + fun setCallback(callback: BillingCallback) { + this.callback = callback + } + + private fun setupBillingClient() { + billingClient = BillingClient.newBuilder(context) + .setListener(this) + .enablePendingPurchases() + .build() + + billingClient?.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "Billing client connected") + } + } + + override fun onBillingServiceDisconnected() { + Log.w(TAG, "Billing service disconnected, reconnecting...") + billingClient?.startConnection(this) + } + }) + } + + fun launchPurchaseFlow(productId: String) { + // Note: launchBillingFlow requires an Activity context + // This is a limitation when initializing from Application + // For now, report an error - full implementation needs activity reference + Log.w(TAG, "Purchase flow requested for $productId - requires Activity context") + callback?.onPurchaseError("Purchase flow not available. Please try again from the app.") + } + + override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List?) { + when (billingResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + purchases?.forEach { purchase -> + handlePurchase(purchase) + } + } + BillingClient.BillingResponseCode.USER_CANCELED -> { + callback?.onPurchaseCancelled() + } + else -> { + callback?.onPurchaseError("Purchase failed: ${billingResult.debugMessage}") + } + } + } + + private fun handlePurchase(purchase: Purchase) { + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + if (!purchase.isAcknowledged) { + val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + + billingClient?.acknowledgePurchase(acknowledgePurchaseParams) { result -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + verifyWithBackend(purchase) + } + } + } else { + verifyWithBackend(purchase) + } + } + } + + private fun verifyWithBackend(purchase: Purchase) { + scope.launch(Dispatchers.IO) { + try { + val productId = purchase.products.firstOrNull() ?: return@launch + + val json = JSONObject().apply { + put("purchaseToken", purchase.purchaseToken) + put("productId", productId) + put("orderId", purchase.orderId ?: "") + } + + val requestBody = json.toString() + .toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url(BACKEND_URL) + .post(requestBody) + .build() + + val response = httpClient.newCall(request).execute() + + withContext(Dispatchers.Main) { + callback?.onPurchaseSuccess(productId, purchase.purchaseToken) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + val productId = purchase.products.firstOrNull() ?: "unknown" + callback?.onPurchaseSuccess(productId, purchase.purchaseToken) + } + } + } + } + + fun restorePurchases() { + val billingClient = this.billingClient ?: return + + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + billingClient.queryPurchasesAsync(params) { billingResult, purchasesList -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + val hasActiveSubscription = purchasesList.any { purchase -> + purchase.purchaseState == Purchase.PurchaseState.PURCHASED + } + + if (hasActiveSubscription) { + purchasesList.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } + .forEach { purchase -> verifyWithBackend(purchase) } + callback?.onRestoreSuccess() + } else { + callback?.onRestoreError("No active subscription found") + } + } else { + callback?.onRestoreError("Failed to query purchases") + } + } + } + + fun checkSubscriptionStatus() { + val billingClient = this.billingClient ?: return + + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + billingClient.queryPurchasesAsync(params) { billingResult, purchasesList -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + val hasActiveSubscription = purchasesList.any { purchase -> + purchase.purchaseState == Purchase.PurchaseState.PURCHASED + } + callback?.onSubscriptionStatus(hasActiveSubscription) + } else { + callback?.onSubscriptionStatus(false) + } + } + } + + fun destroy() { + scope.cancel() + billingClient?.endConnection() + } +} diff --git a/android/app/src/main/java/com/myspeedpuzzling/features/BarcodeScannerActivity.kt b/android/app/src/main/java/com/myspeedpuzzling/features/BarcodeScannerActivity.kt new file mode 100644 index 00000000..e718641d --- /dev/null +++ b/android/app/src/main/java/com/myspeedpuzzling/features/BarcodeScannerActivity.kt @@ -0,0 +1,184 @@ +package com.myspeedpuzzling.features + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import android.util.Size +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.myspeedpuzzling.app.R +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * Full-screen barcode scanner activity using CameraX and ML Kit. + * Scans EAN-8 and EAN-13 barcodes commonly used on puzzle boxes. + */ +class BarcodeScannerActivity : AppCompatActivity() { + private lateinit var cameraExecutor: ExecutorService + private lateinit var previewView: PreviewView + private var hasScanned = false + + companion object { + private const val CAMERA_PERMISSION_REQUEST = 1001 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_barcode_scanner) + + previewView = findViewById(R.id.preview_view) + cameraExecutor = Executors.newSingleThreadExecutor() + + setupCancelButton() + checkCameraPermission() + } + + private fun setupCancelButton() { + findViewById +

-

- - {{ 'membership.subscribe_for_yearly'|trans }} -
{{ 'membership.price_per_year'|trans }} -
+

+ +

+ + +

+ {{ 'membership.purchase_via_app_store'|trans }}

- + {% elseif is_android() %} +
+

+ +

-

- {{ 'membership.redirect_to_stripe'|trans }} -

+

+ +

+
+ +

+ {{ 'membership.purchase_via_play_store'|trans }} +

+ {% endif %} {% endif %} diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml index 5dfe62af..e4c4d09c 100644 --- a/translations/messages.cs.yml +++ b/translations/messages.cs.yml @@ -718,6 +718,12 @@ membership: price_per_month: "150 Kč / měsíc" price_per_year: "1500 Kč / rok" billing_portal_info: "Na platebním portále můžete měnit platební své údaje, zrušit členství, stáhnout faktury nebo účtenky." + manage_in_app_store: "Spravovat v App Store" + manage_in_play_store: "Spravovat v Play Store" + manage_subscription_ios_info: "Spravujte své předplatné v Nastavení vašeho iPhonu/iPadu pod Apple ID > Předplatné." + manage_subscription_android_info: "Spravujte své předplatné v Google Play Store pod Platby a předplatná." + purchase_via_app_store: "Platba bude zpracována přes Apple App Store." + purchase_via_play_store: "Platba bude zpracována přes Google Play Store." full_description: |
  • Velký dík za podporu vývoje platformy
  • diff --git a/translations/messages.de.yml b/translations/messages.de.yml index 5990ed8a..71152147 100644 --- a/translations/messages.de.yml +++ b/translations/messages.de.yml @@ -701,6 +701,12 @@ membership: price_per_month: "€6 / Monat" price_per_year: "€60 / Jahr" billing_portal_info: "Im Zahlungsportal können Sie Ihre Zahlungsdetails aktualisieren, Ihre Mitgliedschaft kündigen oder Rechnungen oder Belege herunterladen." + manage_in_app_store: "Im App Store verwalten" + manage_in_play_store: "Im Play Store verwalten" + manage_subscription_ios_info: "Verwalten Sie Ihr Abonnement in den Einstellungen Ihres iPhones/iPads unter Apple-ID > Abonnements." + manage_subscription_android_info: "Verwalten Sie Ihr Abonnement im Google Play Store unter Zahlungen und Abos." + purchase_via_app_store: "Zahlung wird über den Apple App Store abgewickelt." + purchase_via_play_store: "Zahlung wird über den Google Play Store abgewickelt." full_description: |
    • Ein großes Dankeschön für die Unterstützung der Plattform-Entwicklung
    • diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 1e9f6c42..3b241db4 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -719,6 +719,12 @@ membership: price_per_month: "€6 / month" price_per_year: "€60 / year" billing_portal_info: "In the payment portal, you can update your payment details, cancel your membership, or download invoices or receipts." + manage_in_app_store: "Manage in App Store" + manage_in_play_store: "Manage in Play Store" + manage_subscription_ios_info: "Manage your subscription in your iPhone/iPad Settings under Apple ID > Subscriptions." + manage_subscription_android_info: "Manage your subscription in Google Play Store under Payments & subscriptions." + purchase_via_app_store: "Payment will be processed through Apple App Store." + purchase_via_play_store: "Payment will be processed through Google Play Store." full_description: |
      • A big thank you for supporting the platform's development
      • diff --git a/translations/messages.es.yml b/translations/messages.es.yml index 52419d75..53e523d4 100644 --- a/translations/messages.es.yml +++ b/translations/messages.es.yml @@ -723,6 +723,12 @@ membership: price_per_month: "€6 / mes" price_per_year: "€60 / año" billing_portal_info: "En el portal de pago, puedes actualizar tus detalles de pago, cancelar tu membresía o descargar facturas o recibos." + manage_in_app_store: "Administrar en App Store" + manage_in_play_store: "Administrar en Play Store" + manage_subscription_ios_info: "Administre su suscripción en Ajustes de su iPhone/iPad en ID de Apple > Suscripciones." + manage_subscription_android_info: "Administre su suscripción en Google Play Store en Pagos y suscripciones." + purchase_via_app_store: "El pago se procesará a través de Apple App Store." + purchase_via_play_store: "El pago se procesará a través de Google Play Store." full_description: |
        • Un gran agradecimiento por apoyar el desarrollo de la plataforma
        • diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 473ee6e7..b7cfdd5c 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -723,6 +723,12 @@ membership: price_per_month: "6€ / mois" price_per_year: "60€ / an" billing_portal_info: "Dans le portail de paiement, vous pouvez mettre à jour vos détails de paiement, annuler votre adhésion, ou télécharger des factures ou reçus." + manage_in_app_store: "Gérer dans l'App Store" + manage_in_play_store: "Gérer dans Play Store" + manage_subscription_ios_info: "Gérez votre abonnement dans les Réglages de votre iPhone/iPad sous Identifiant Apple > Abonnements." + manage_subscription_android_info: "Gérez votre abonnement dans Google Play Store sous Paiements et abonnements." + purchase_via_app_store: "Le paiement sera traité via l'Apple App Store." + purchase_via_play_store: "Le paiement sera traité via Google Play Store." full_description: |
          • Un grand merci pour soutenir le développement de la plateforme
          • diff --git a/translations/messages.ja.yml b/translations/messages.ja.yml index 86acc32b..bc49f5dc 100644 --- a/translations/messages.ja.yml +++ b/translations/messages.ja.yml @@ -383,6 +383,12 @@ membership: price_per_month: "€6 / 月" price_per_year: "€60 / 年" billing_portal_info: "支払いポータルでは、支払い詳細の更新、メンバーシップのキャンセル、請求書や領収書のダウンロードができます。" + manage_in_app_store: "App Storeで管理" + manage_in_play_store: "Play Storeで管理" + manage_subscription_ios_info: "iPhone/iPadの設定アプリでApple ID > サブスクリプションからサブスクリプションを管理できます。" + manage_subscription_android_info: "Google Play Storeの支払いと定期購入からサブスクリプションを管理できます。" + purchase_via_app_store: "お支払いはApple App Store経由で処理されます。" + purchase_via_play_store: "お支払いはGoogle Play Store経由で処理されます。" full_description: |
            • プラットフォーム開発をサポートしてくださることへの大きな感謝