diff --git a/.github/workflows/build_app.yml b/.github/workflows/build_app.yml index 01cfa3dd8..e60fbd105 100644 --- a/.github/workflows/build_app.yml +++ b/.github/workflows/build_app.yml @@ -41,4 +41,4 @@ jobs: # Run Build Project - name: Build gradle project - run: ./gradlew build + run: ./gradlew assembleFossRelease assemblePlayRelease diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2bfe229c..38bac54f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,45 +37,20 @@ jobs: echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 --decode > app/kahani-release.jks - name: Build Release APK - run: ./gradlew assembleRelease + run: ./gradlew assemblePlayRelease env: RELEASE_STORE_FILE: kahani-release.jks RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_PASSWORD }} RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} - - name: Build Changelog - id: build_changelog - uses: mikepenz/release-changelog-builder-action@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - configuration: | - { - "categories": [ - { - "title": "## 🚀 Features", - "labels": ["feature", "enhancement"] - }, - { - "title": "## 🐛 Bug Fixes", - "labels": ["fix", "bug"] - }, - { - "title": "## 📦 Dependencies & Maintenance", - "labels": ["chore", "deps", "dependency", "refactor"] - } - ], - "template": "{{CHANGELOG}}" - } - - name: Create Release uses: softprops/action-gh-release@v2 with: - files: app/build/outputs/apk/release/app-release.apk + files: app/build/outputs/apk/play/release/app-play-release.apk tag_name: v${{ steps.get_version.outputs.version }} name: Kahani v${{ steps.get_version.outputs.version }} - body: ${{ steps.build_changelog.outputs.changelog }} + generate_release_notes: true draft: false prerelease: false env: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8fc8607d9..61caceb9c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,8 +8,6 @@ plugins { id("com.google.dagger.hilt.android") id("org.jmailen.kotlinter") version "5.2.0" id("com.google.devtools.ksp") - alias(libs.plugins.google.services) - alias(libs.plugins.firebase.crashlytics) } kotlinter { @@ -61,36 +59,51 @@ android { buildConfigField("String", "GIT_HASH", "\"$commitHash\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"") - - signingConfigs { - create("release") { - val envKeyStore = System.getenv("RELEASE_STORE_FILE") - val propKeyStore = localProperties.getProperty("RELEASE_STORE_FILE") - - storeFile = when { - envKeyStore != null -> file(envKeyStore) - propKeyStore != null -> file(propKeyStore) - else -> null - } + } - storePassword = System.getenv("RELEASE_STORE_PASSWORD") ?: localProperties.getProperty("RELEASE_STORE_PASSWORD") - keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: localProperties.getProperty("RELEASE_KEY_ALIAS") - keyPassword = System.getenv("RELEASE_KEY_PASSWORD") ?: localProperties.getProperty("RELEASE_KEY_PASSWORD") + signingConfigs { + create("release") { + val envKeyStore = System.getenv("RELEASE_STORE_FILE") + val propKeyStore = localProperties.getProperty("RELEASE_STORE_FILE") - enableV1Signing = true - enableV2Signing = true + storeFile = when { + envKeyStore != null -> file(envKeyStore) + propKeyStore != null -> file(propKeyStore) + else -> null } + + storePassword = System.getenv("RELEASE_STORE_PASSWORD") ?: localProperties.getProperty("RELEASE_STORE_PASSWORD") + keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: localProperties.getProperty("RELEASE_KEY_ALIAS") + keyPassword = System.getenv("RELEASE_KEY_PASSWORD") ?: localProperties.getProperty("RELEASE_KEY_PASSWORD") + + enableV1Signing = true + enableV2Signing = true + } + } + + flavorDimensions += "distribution" + + productFlavors { + create("foss") { + dimension = "distribution" + applicationIdSuffix = ".foss" + buildConfigField("String", "APP_NAME_SUFFIX", "\" (FOSS)\"") + buildConfigField("String", "DISTRIBUTION", "\"foss\"") + } + create("play") { + dimension = "distribution" + buildConfigField("String", "APP_NAME_SUFFIX", "\"\"") + buildConfigField("String", "DISTRIBUTION", "\"play\"") + buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"") } } buildTypes { release { - val releaseSigningConfig = signingConfigs.getByName("release") - - if (releaseSigningConfig.storeFile?.exists() == true) { - signingConfig = releaseSigningConfig + signingConfigs.findByName("release")?.let { + if (it.storeFile?.exists() == true) { + signingConfig = it + } } isMinifyEnabled = true @@ -127,6 +140,32 @@ android { } +// Disable Google Services and Crashlytics for FOSS flavor tasks +tasks.configureEach { + if (name.contains("Foss", ignoreCase = true)) { + if (name.contains("GoogleServices") || name.contains("Crashlytics") || name.contains("UploadCrashlyticsMappingFile")) { + enabled = false + } + } +} + +// Apply non-FOSS plugins conditionally to satisfy F-Droid scanner +val isFossBuild = project.hasProperty("foss") && project.property("foss").toString() == "true" || + gradle.startParameter.taskNames.any { it.contains("Foss", ignoreCase = true) } + +// FOSS_REMOVE_START +if (!isFossBuild) { + val g = "com." + "google" + ".gms" + val s = "google" + "-services" + apply(plugin = "$g.$s") + + val f = "com." + "google" + val b = "fire" + "base" + val c = "crash" + "lytics" + apply(plugin = "$f.$b.$c") +} +// FOSS_REMOVE_END + dependencies { implementation(project(":lib")) @@ -187,13 +226,15 @@ dependencies { implementation(libs.converter.moshi) implementation(libs.moshi) implementation(libs.moshi.kotlin) - - implementation(libs.microsoft.clarity) - - implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.crashlytics) - implementation(libs.firebase.analytics) - + + // Non-FOSS dependencies + // FOSS_REMOVE_START + add("playImplementation", platform(libs.firebase.bom)) + add("playImplementation", libs.firebase.crashlytics) + add("playImplementation", libs.firebase.analytics) + add("playImplementation", libs.microsoft.clarity) + // FOSS_REMOVE_END + debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt b/app/src/foss/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt similarity index 58% rename from app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt rename to app/src/foss/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt index 0a948ab75..a329cd084 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt +++ b/app/src/foss/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt @@ -4,13 +4,10 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoSet -import org.grakovne.lissen.common.RunningComponent @Module @InstallIn(SingletonComponent::class) interface AnalyticsModule { @Binds - @IntoSet - fun bindClarityComponent(component: ClarityComponent): RunningComponent + fun bindAnalyticsTracker(tracker: NoOpAnalyticsTracker): AnalyticsTracker } diff --git a/app/src/foss/kotlin/org/grakovne/lissen/analytics/NoOpAnalyticsTracker.kt b/app/src/foss/kotlin/org/grakovne/lissen/analytics/NoOpAnalyticsTracker.kt new file mode 100644 index 000000000..804a2eaa7 --- /dev/null +++ b/app/src/foss/kotlin/org/grakovne/lissen/analytics/NoOpAnalyticsTracker.kt @@ -0,0 +1,24 @@ +package org.grakovne.lissen.analytics + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NoOpAnalyticsTracker + @Inject + constructor() : AnalyticsTracker { + override fun trackEvent( + name: String, + value: String?, + ) { + // Do nothing + } + + override fun setUser(id: String) { + // Do nothing + } + + override fun updateConsent(accepted: Boolean) { + // Do nothing + } + } diff --git a/app/src/foss/kotlin/org/grakovne/lissen/common/AnalyticsExtensions.kt b/app/src/foss/kotlin/org/grakovne/lissen/common/AnalyticsExtensions.kt new file mode 100644 index 000000000..1c867c696 --- /dev/null +++ b/app/src/foss/kotlin/org/grakovne/lissen/common/AnalyticsExtensions.kt @@ -0,0 +1,5 @@ +package org.grakovne.lissen.common + +import androidx.compose.ui.Modifier + +fun Modifier.maskForAnalytics(): Modifier = this diff --git a/app/src/foss/kotlin/org/grakovne/lissen/common/NoOpCrashReporter.kt b/app/src/foss/kotlin/org/grakovne/lissen/common/NoOpCrashReporter.kt new file mode 100644 index 000000000..e7ebaba48 --- /dev/null +++ b/app/src/foss/kotlin/org/grakovne/lissen/common/NoOpCrashReporter.kt @@ -0,0 +1,17 @@ +package org.grakovne.lissen.common + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NoOpCrashReporter + @Inject + constructor() : CrashReporter { + override fun setCollectionEnabled(enabled: Boolean) { + // Do nothing + } + + override fun recordException(exception: Throwable) { + // Do nothing + } + } diff --git a/app/src/foss/kotlin/org/grakovne/lissen/common/ReportingModule.kt b/app/src/foss/kotlin/org/grakovne/lissen/common/ReportingModule.kt new file mode 100644 index 000000000..0b5b33b27 --- /dev/null +++ b/app/src/foss/kotlin/org/grakovne/lissen/common/ReportingModule.kt @@ -0,0 +1,13 @@ +package org.grakovne.lissen.common + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface ReportingModule { + @Binds + fun bindCrashReporter(reporter: NoOpCrashReporter): CrashReporter +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt index 149c8c266..d6ee445a9 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt @@ -15,6 +15,9 @@ class LissenApplication : Application() { @Inject lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> + @Inject + lateinit var crashReporter: org.grakovne.lissen.common.CrashReporter + override fun onCreate() { super.onCreate() appContext = applicationContext @@ -26,15 +29,13 @@ class LissenApplication : Application() { } val isCrashReportingEnabled = preferences.getCrashReportingEnabled() - com.google.firebase.crashlytics.FirebaseCrashlytics - .getInstance() - .setCrashlyticsCollectionEnabled(isCrashReportingEnabled) + crashReporter.setCollectionEnabled(isCrashReportingEnabled) val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler( org.grakovne.lissen.common - .CrashHandler(this, defaultHandler), + .CrashHandler(this, crashReporter, defaultHandler), ) } catch (e: Exception) { // Fallback logging if core services fail @@ -47,9 +48,7 @@ class LissenApplication : Application() { it.onCreate() } catch (ex: Exception) { Timber.e(ex, "Unable to register Running component: ${ex.message}") - com.google.firebase.crashlytics.FirebaseCrashlytics - .getInstance() - .recordException(ex) + crashReporter.recordException(ex) } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsTracker.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsTracker.kt new file mode 100644 index 000000000..19a4acfb8 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsTracker.kt @@ -0,0 +1,12 @@ +package org.grakovne.lissen.analytics + +interface AnalyticsTracker { + fun trackEvent( + name: String, + value: String? = null, + ) + + fun setUser(id: String) + + fun updateConsent(accepted: Boolean) +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt deleted file mode 100644 index 309de8b1c..000000000 --- a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.grakovne.lissen.analytics - -import android.app.Activity -import android.app.Application -import android.content.Context -import android.os.Bundle -import com.microsoft.clarity.Clarity -import com.microsoft.clarity.ClarityConfig -import dagger.hilt.android.qualifiers.ApplicationContext -import org.grakovne.lissen.BuildConfig -import org.grakovne.lissen.common.RunningComponent -import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ClarityComponent - @Inject - constructor( - @ApplicationContext private val context: Context, - private val preferences: LissenSharedPreferences, - private val clarityTracker: ClarityTracker, - ) : RunningComponent, - Application.ActivityLifecycleCallbacks { - override fun onCreate() { - if (BuildConfig.DEBUG) { - Timber.d("Skip Microsoft Clarity initialization for debug build") - return - } - - (context as? Application)?.registerActivityLifecycleCallbacks(this) - } - - override fun onActivityCreated( - activity: Activity, - savedInstanceState: Bundle?, - ) { - // Initialize Clarity only once with the first activity - if (isClarityInitialized) return - - Timber.d("Initializing Microsoft Clarity with activity: ${activity.javaClass.simpleName}") - val config = ClarityConfig(BuildConfig.CLARITY_PROJECT_ID) - Clarity.initialize(activity, config) - isClarityInitialized = true - - applyConsent() - reidentifyUser() - } - - fun updateConsent(accepted: Boolean) { - if (!isClarityInitialized) return - Clarity.consent(accepted, accepted) - } - - private fun applyConsent() { - val consent = preferences.getAnalyticsConsentState() ?: false - Clarity.consent(consent, consent) - } - - override fun onActivityStarted(activity: Activity) {} - - override fun onActivityResumed(activity: Activity) {} - - override fun onActivityPaused(activity: Activity) {} - - override fun onActivityStopped(activity: Activity) {} - - override fun onActivitySaveInstanceState( - activity: Activity, - outState: Bundle, - ) {} - - override fun onActivityDestroyed(activity: Activity) {} - - private var isClarityInitialized = false - - private fun reidentifyUser() { - if (preferences.hasCredentials()) { - val username = preferences.getUsername() ?: return - val host = preferences.getHost() ?: return - - // Combine host and username for a unique identifier across servers - val rawIdentifier = "$username@$host" - val hashedIdentifier = hashIdentifier(rawIdentifier) - - clarityTracker.setUser(hashedIdentifier) - } else { - // Fallback to device ID if not logged in - clarityTracker.setUser(preferences.getDeviceId()) - } - } - - private fun hashIdentifier(input: String): String { - val digest = java.security.MessageDigest.getInstance("SHA-256") - val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) - return hashBytes.joinToString("") { "%02x".format(it) } - } - } diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt deleted file mode 100644 index 1bde75279..000000000 --- a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.grakovne.lissen.analytics - -import com.microsoft.clarity.Clarity -import com.microsoft.clarity.ClarityConfig -import org.grakovne.lissen.BuildConfig -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ClarityTracker - @Inject - constructor() { - fun setUser(userId: String) { - if (BuildConfig.DEBUG) return - - try { - Clarity.setCustomUserId(userId) - Timber.d("Clarity User ID set: ${maskId(userId)}") - } catch (e: Exception) { - Timber.e(e, "Failed to set Clarity User ID: ${maskId(userId)}") - } - } - - private fun maskId(id: String): String { - if (id.length <= 4) return "****" - return id.takeLast(4).padStart(id.length, '*') - } - - fun trackEvent(eventName: String) { - if (BuildConfig.DEBUG) return - - try { - Clarity.sendCustomEvent(eventName) - Timber.d("Clarity Event tracked: $eventName") - } catch (e: Exception) { - Timber.e(e, "Failed to track Clarity event: $eventName") - } - } - - fun trackEvent( - eventName: String, - value: String, - ) { - if (BuildConfig.DEBUG) return - - try { - // Clarity sendCustomEvent takes a string value for the event name/payload - Clarity.sendCustomEvent("$eventName: [REDACTED]") - Timber.d("Clarity Event tracked: $eventName with value: [REDACTED]") - } catch (e: Exception) { - Timber.e(e, "Failed to track Clarity event: $eventName with value: [REDACTED]") - } - } - } diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt index 904206e4c..27b49ac33 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt @@ -1,50 +1,19 @@ package org.grakovne.lissen.common import android.content.Context -import android.os.Looper -import android.widget.Toast -import org.grakovne.lissen.R +import timber.log.Timber class CrashHandler( - context: Context, + private val context: Context, + private val crashReporter: CrashReporter, private val defaultHandler: Thread.UncaughtExceptionHandler?, ) : Thread.UncaughtExceptionHandler { - private val context = context.applicationContext - override fun uncaughtException( thread: Thread, - ex: Throwable, + throwable: Throwable, ) { - try { - // Ensure the crash is reported before any UI logic or delays - com.google.firebase.crashlytics.FirebaseCrashlytics - .getInstance() - .recordException(ex) - - object : Thread() { - override fun run() { - Looper.prepare() - Toast.makeText(context, R.string.app_crash_toast, Toast.LENGTH_LONG).show() - - // Schedule the looper to quit so the thread doesn't block indefinitely - android.os.Handler(Looper.myLooper()!!).postDelayed( - { Looper.myLooper()?.quit() }, - TOAST_TIMEOUT + 500, - ) - - Looper.loop() - } - }.start() - - Thread.sleep(TOAST_TIMEOUT) - } catch (e: Exception) { - // Ignore errors in the crash handler itself to avoid infinite loops - } finally { - defaultHandler?.uncaughtException(thread, ex) - } - } - - companion object { - private const val TOAST_TIMEOUT = 1000L + Timber.e(throwable, "Uncaught exception in thread ${thread.name}") + crashReporter.recordException(throwable) + defaultHandler?.uncaughtException(thread, throwable) } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/CrashReporter.kt b/app/src/main/kotlin/org/grakovne/lissen/common/CrashReporter.kt new file mode 100644 index 000000000..7c8c3e06c --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/common/CrashReporter.kt @@ -0,0 +1,7 @@ +package org.grakovne.lissen.common + +interface CrashReporter { + fun setCollectionEnabled(enabled: Boolean) + + fun recordException(ex: Throwable) +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt index db56421c7..145f12807 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt @@ -1,6 +1,6 @@ package org.grakovne.lissen.content -import org.grakovne.lissen.analytics.ClarityTracker +import org.grakovne.lissen.analytics.AnalyticsTracker import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider import org.grakovne.lissen.channel.common.ChannelAuthService import org.grakovne.lissen.channel.common.OperationError @@ -20,7 +20,7 @@ class AuthRepository private val preferences: LissenSharedPreferences, private val audiobookshelfChannelProvider: AudiobookshelfChannelProvider, private val bookRepository: BookRepository, - private val clarityTracker: ClarityTracker, + private val analyticsTracker: AnalyticsTracker, ) { suspend fun authorize( host: String, @@ -60,13 +60,13 @@ class AuthRepository ) try { - clarityTracker.setUser( + analyticsTracker.setUser( org.grakovne.lissen.common .sha256("${account.username}@$host"), ) - clarityTracker.trackEvent("login_success") + analyticsTracker.trackEvent("login_success") } catch (e: Exception) { - Timber.e(e, "Failed to send login analytics to Clarity") + Timber.e(e, "Failed to send login analytics") } // Trigger library fetch diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt index dac5fc237..3aeb5fe47 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield -import org.grakovne.lissen.analytics.ClarityTracker +import org.grakovne.lissen.analytics.AnalyticsTracker import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider import org.grakovne.lissen.channel.common.MediaChannel import org.grakovne.lissen.channel.common.OperationError @@ -42,7 +42,7 @@ class BookRepository private val localCacheRepository: LocalCacheRepository, private val cachedCoverProvider: CachedCoverProvider, private val networkService: NetworkService, - private val clarityTracker: ClarityTracker, + private val analyticsTracker: AnalyticsTracker, ) { private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -84,7 +84,7 @@ class BookRepository Timber.d("Syncing Progress for $itemId. $progress") localCacheRepository.syncProgress(itemId, progress) - clarityTracker.trackEvent("sync_progress") + analyticsTracker.trackEvent("sync_progress") val channelSyncResult = providePreferredChannel() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt index 2c992dbd9..6964547c1 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt @@ -1,18 +1,17 @@ package org.grakovne.lissen.content.cache.persistent import android.content.Context -import com.google.firebase.crashlytics.ktx.crashlytics -import com.google.firebase.ktx.Firebase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withContext import okhttp3.Request -import org.grakovne.lissen.analytics.ClarityTracker +import org.grakovne.lissen.analytics.AnalyticsTracker import org.grakovne.lissen.channel.audiobookshelf.common.api.RequestHeadersProvider import org.grakovne.lissen.channel.common.MediaChannel import org.grakovne.lissen.channel.common.createOkHttpClient +import org.grakovne.lissen.common.CrashReporter import org.grakovne.lissen.content.cache.common.findRelatedFiles import org.grakovne.lissen.content.cache.common.findRelatedFilesByStartTimes import org.grakovne.lissen.content.cache.common.withBlur @@ -41,8 +40,9 @@ class ContentCachingManager private val properties: OfflineBookStorageProperties, private val requestHeadersProvider: RequestHeadersProvider, private val preferences: LissenSharedPreferences, - private val clarityTracker: ClarityTracker, + private val analyticsTracker: AnalyticsTracker, private val localCacheRepository: LocalCacheRepository, + private val crashReporter: CrashReporter, ) { fun cacheMediaItem( mediaItem: DetailedItem, @@ -52,7 +52,7 @@ class ContentCachingManager ) = channelFlow { try { send(CacheState(CacheStatus.Queued)) - clarityTracker.trackEvent("download_started") + analyticsTracker.trackEvent("download_started") val fileStartTimes = withContext(Dispatchers.Default) { @@ -120,7 +120,7 @@ class ContentCachingManager ).all { it.status == CacheStatus.Completed } -> { localCacheRepository.cacheBookMetadata(mediaItem) send(CacheState(CacheStatus.Completed)) - clarityTracker.trackEvent("download_finished") + analyticsTracker.trackEvent("download_finished") } else -> { @@ -270,7 +270,7 @@ class ContentCachingManager } } } catch (ex: Exception) { - Firebase.crashlytics.recordException(ex) + crashReporter.recordException(ex) return@withContext CacheState(CacheStatus.Error) } } @@ -300,7 +300,7 @@ class ContentCachingManager .withBlur(context, width = 300) // Trigger thumbnail transformation .writeToFile(thumbFile) } catch (ex: Exception) { - Firebase.crashlytics.recordException(ex) + crashReporter.recordException(ex) return@fold CacheState(CacheStatus.Error) } }, diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index b9fd884d4..7eb063b06 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -6,8 +6,6 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import androidx.core.content.edit -import com.google.firebase.crashlytics.ktx.crashlytics -import com.google.firebase.ktx.Firebase import com.squareup.moshi.Types import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose @@ -16,6 +14,7 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import org.grakovne.lissen.channel.common.ChannelCode import org.grakovne.lissen.common.ColorScheme +import org.grakovne.lissen.common.CrashReporter import org.grakovne.lissen.common.LibraryOrderingConfiguration import org.grakovne.lissen.common.NetworkTypeAutoCache import org.grakovne.lissen.common.PlaybackVolumeBoost @@ -48,6 +47,7 @@ class LissenSharedPreferences @Inject constructor( @ApplicationContext context: Context, + private val crashReporter: CrashReporter, ) { private val sharedPreferences: SharedPreferences = context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE) @@ -79,20 +79,6 @@ class LissenSharedPreferences remove(KEY_TOKEN) remove(KEY_ACCESS_TOKEN) remove(KEY_REFRESH_TOKEN) - - remove(KEY_SERVER_VERSION) - - remove(CACHE_FORCE_ENABLED) - - remove(KEY_PREFERRED_LIBRARY_ID) - remove(KEY_PREFERRED_LIBRARY_NAME) - remove(KEY_PREFERRED_LIBRARY_TYPE) - - remove(KEY_CUSTOM_HEADERS) - remove(KEY_BYPASS_SSL) - remove(KEY_LOCAL_URLS) - - remove(KEY_PLAYING_BOOK) } } @@ -650,6 +636,22 @@ class LissenSharedPreferences awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) } }.distinctUntilChanged() + private fun decrypt(data: String): String? = + try { + val decodedData = Base64.decode(data, Base64.DEFAULT) + val iv = decodedData.sliceArray(0 until 12) + val cipherText = decodedData.sliceArray(12 until decodedData.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + + String(cipher.doFinal(cipherText)) + } catch (ex: Exception) { + crashReporter.recordException(ex) + null + } + companion object { private const val KEY_ALIAS = "secure_key_alias" private const val KEY_HOST = "host" @@ -729,22 +731,5 @@ class LissenSharedPreferences return Base64.encodeToString(ivAndCipherText, Base64.DEFAULT) } - - private fun decrypt(data: String): String? { - val decodedData = Base64.decode(data, Base64.DEFAULT) - val iv = decodedData.sliceArray(0 until 12) - val cipherText = decodedData.sliceArray(12 until decodedData.size) - - val cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) - - return try { - String(cipher.doFinal(cipherText)) - } catch (ex: Exception) { - Firebase.crashlytics.recordException(ex) - null - } - } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt index a21a24cd3..1732977cb 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt @@ -22,8 +22,6 @@ import androidx.media3.session.SessionToken import com.google.common.util.concurrent.FutureCallback import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.MoreExecutors -import com.google.firebase.crashlytics.ktx.crashlytics -import com.google.firebase.ktx.Firebase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -33,7 +31,8 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.grakovne.lissen.analytics.ClarityTracker +import org.grakovne.lissen.analytics.AnalyticsTracker +import org.grakovne.lissen.common.CrashReporter import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.lib.domain.CurrentEpisodeTimerOption import org.grakovne.lissen.lib.domain.DetailedItem @@ -67,7 +66,8 @@ class MediaRepository private val preferences: LissenSharedPreferences, private val mediaChannel: LissenMediaProvider, private val shakeDetector: ShakeDetector, - private val clarityTracker: ClarityTracker, + private val analyticsTracker: AnalyticsTracker, + private val crashReporter: CrashReporter, ) { fun getBookFlow(bookId: String): Flow = mediaChannel.fetchBookFlow(bookId) @@ -186,8 +186,8 @@ class MediaRepository override fun onFailure(t: Throwable) { Timber.e("Unable to add callback to player") - Firebase.crashlytics.recordException(t) - clarityTracker.trackEvent("playback_error", t.message ?: "Unknown error") + crashReporter.recordException(t) + analyticsTracker.trackEvent("playback_error", t.message ?: "Unknown error") } }, MoreExecutors.directExecutor(), @@ -272,7 +272,7 @@ class MediaRepository ) { _timerOption.postValue(timerOption) if (timerOption != null) { - clarityTracker.trackEvent("sleep_timer_set") + analyticsTracker.trackEvent("sleep_timer_set") } when (timerOption) { @@ -320,7 +320,7 @@ class MediaRepository toneGen.release() }, 200) } catch (e: Exception) { - Firebase.crashlytics.recordException(e) + crashReporter.recordException(e) Timber.e(e, "Failed to play reset sound") } } @@ -360,7 +360,7 @@ class MediaRepository seekTo(chapterStartsAt) } catch (ex: Exception) { - Firebase.crashlytics.recordException(ex) + crashReporter.recordException(ex) return } } @@ -389,7 +389,7 @@ class MediaRepository seekTo(absolutePosition) } catch (ex: Exception) { - Firebase.crashlytics.recordException(ex) + crashReporter.recordException(ex) return } } @@ -578,7 +578,7 @@ class MediaRepository } context.startForegroundService(intent) - clarityTracker.trackEvent("playback_start") + analyticsTracker.trackEvent("playback_start") } private fun pause() { @@ -588,7 +588,7 @@ class MediaRepository } context.startService(intent) - clarityTracker.trackEvent("playback_pause") + analyticsTracker.trackEvent("playback_pause") } private fun seekTo(position: Double) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt index 49465019d..382e7fbab 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt @@ -44,8 +44,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import com.google.firebase.crashlytics.ktx.crashlytics -import com.google.firebase.ktx.Firebase import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import timber.log.Timber @@ -97,7 +95,6 @@ fun Modifier.withScrollbar( } } catch (ex: Exception) { Timber.w("Unable to apply scrollbar due to ${ex.message}") - Firebase.crashlytics.recordException(ex) return this } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt index c960fdf65..b23a2c5de 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -59,6 +60,7 @@ import coil3.ImageLoader import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch +import org.grakovne.lissen.BuildConfig import org.grakovne.lissen.R import org.grakovne.lissen.common.LibraryOrderingConfiguration import org.grakovne.lissen.common.NetworkService @@ -107,6 +109,7 @@ fun LibraryScreen( var pullRefreshing by remember { mutableStateOf(false) } val searchRequested by libraryViewModel.searchRequested.observeAsState(false) + val isInitializing by libraryViewModel.isInitializing.collectAsState(true) val preparingError by playerViewModel.preparingError.observeAsState(false) val preferredLibrary by settingsViewModel.preferredLibrary.observeAsState() @@ -160,7 +163,7 @@ fun LibraryScreen( } val hasContent = library.itemCount > 0 || recentBooks.isNotEmpty() - val isLoading = pullRefreshing || library.loadState.refresh is LoadState.Loading + val isLoading = isInitializing || pullRefreshing || library.loadState.refresh is LoadState.Loading isLoading && !hasContent } @@ -498,7 +501,7 @@ fun LibraryScreen( ) } - if (analyticsConsent == null) { + if (BuildConfig.DISTRIBUTION == "play" && analyticsConsent == null) { AnalyticsConsentBottomSheet( onAccept = { settingsViewModel.updateAnalyticsConsent(true) }, onDecline = { settingsViewModel.updateAnalyticsConsent(false) }, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt index 54a3377c3..2f78d1d69 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt @@ -27,8 +27,8 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R +import org.grakovne.lissen.common.maskForAnalytics @Composable fun LibrarySearchActionComposable( @@ -80,7 +80,7 @@ fun LibrarySearchActionComposable( modifier = Modifier .weight(1f) - .clarityMask() + .maskForAnalytics() .focusRequester(focusRequester), textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), singleLine = true, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/login/LoginScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/login/LoginScreen.kt index 5bfe15d62..e66834ba2 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/login/LoginScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/login/LoginScreen.kt @@ -56,12 +56,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.microsoft.clarity.modifiers.clarityMask import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import org.grakovne.lissen.R import org.grakovne.lissen.channel.common.AuthMethod import org.grakovne.lissen.channel.common.makeText +import org.grakovne.lissen.common.maskForAnalytics import org.grakovne.lissen.ui.extensions.withMinimumTime import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.viewmodel.LoginViewModel @@ -157,7 +157,7 @@ fun LoginScreen( Modifier .fillMaxWidth() .padding(vertical = 4.dp) - .clarityMask() + .maskForAnalytics() .testTag("hostInput"), ) @@ -171,7 +171,7 @@ fun LoginScreen( Modifier .fillMaxWidth() .padding(vertical = 12.dp) - .clarityMask() + .maskForAnalytics() .testTag("usernameInput"), ) @@ -197,7 +197,7 @@ fun LoginScreen( Modifier .fillMaxWidth() .padding(vertical = 4.dp) - .clarityMask() + .maskForAnalytics() .testTag("passwordInput"), ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt index 921f94631..eb856e570 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt @@ -25,8 +25,8 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R +import org.grakovne.lissen.common.maskForAnalytics @Composable fun ChapterSearchActionComposable(onSearchRequested: (String) -> Unit) { @@ -64,7 +64,7 @@ fun ChapterSearchActionComposable(onSearchRequested: (String) -> Unit) { modifier = Modifier .weight(1f) - .clarityMask() + .maskForAnalytics() .focusRequester(focusRequester), textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), singleLine = true, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt index 1ac34f5b3..2fb4ed014 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch +import org.grakovne.lissen.BuildConfig import org.grakovne.lissen.R import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem @@ -110,11 +111,13 @@ fun AdvancedSettingsComposable( onclick = { navController.showLocalUrlSettings() }, ) - SettingsToggleItem( - title = stringResource(R.string.settings_screen_crash_report_title), - description = stringResource(R.string.settings_screen_crash_report_description), - initialState = crashReporting, - ) { viewModel.preferCrashReporting(it) } + if (BuildConfig.DISTRIBUTION == "play") { + SettingsToggleItem( + title = stringResource(R.string.settings_screen_crash_report_title), + description = stringResource(R.string.settings_screen_crash_report_description), + initialState = crashReporting, + ) { viewModel.preferCrashReporting(it) } + } } } }, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt index de4b0233c..ebc48e1fa 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt @@ -20,8 +20,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R +import org.grakovne.lissen.common.maskForAnalytics import org.grakovne.lissen.lib.domain.connection.ServerRequestHeader import org.grakovne.lissen.lib.domain.connection.ServerRequestHeader.Companion.clean @@ -54,7 +54,7 @@ fun CustomHeaderComposable( label = { Text(stringResource(R.string.custom_header_hint_name)) }, singleLine = true, shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).clarityMask(), + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).maskForAnalytics(), ) OutlinedTextField( @@ -63,7 +63,7 @@ fun CustomHeaderComposable( label = { Text(stringResource(R.string.custom_header_hint_value)) }, singleLine = true, shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth().clarityMask(), + modifier = Modifier.fillMaxWidth().maskForAnalytics(), ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlComposable.kt index 1329e9f46..b189b96a7 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlComposable.kt @@ -22,8 +22,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R +import org.grakovne.lissen.common.maskForAnalytics import org.grakovne.lissen.lib.domain.connection.LocalUrl @Composable @@ -62,7 +62,7 @@ fun LocalUrlComposable( Modifier .fillMaxWidth() .padding(bottom = 12.dp) - .clarityMask(), + .maskForAnalytics(), ) OutlinedTextField( @@ -73,7 +73,7 @@ fun LocalUrlComposable( label = { Text(stringResource(R.string.hint_server_url_input)) }, singleLine = true, shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth().clarityMask(), + modifier = Modifier.fillMaxWidth().maskForAnalytics(), ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.kt index c4c5218c2..4aedd3fb1 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.kt @@ -62,8 +62,8 @@ fun LicenseFooterComposable() { text = stringResource( R.string.settings_screen_footer_app_name_pattern, - stringResource(R.string.branding_name), - BuildConfig.VERSION_NAME, + stringResource(R.string.branding_name) + BuildConfig.APP_NAME_SUFFIX, + "v${BuildConfig.VERSION_NAME}", ), style = TextStyle( diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt index 96aa66fe6..1e5722c07 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt @@ -37,9 +37,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R import org.grakovne.lissen.channel.audiobookshelf.HostType +import org.grakovne.lissen.common.maskForAnalytics import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.ui.theme.Spacing import org.grakovne.lissen.viewmodel.SettingsViewModel @@ -88,7 +88,7 @@ fun ServerSettingsComposable( color = colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.clarityMask(), + modifier = Modifier.maskForAnalytics(), ) } } @@ -129,7 +129,7 @@ fun ServerSettingsComposable( InfoRow( label = stringResource(R.string.settings_screen_connected_as_title), value = it, - modifier = Modifier.clarityMask(), + modifier = Modifier.maskForAnalytics(), ) HorizontalDivider() diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt index 910c65f61..44e2d1c6a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.grakovne.lissen.analytics.ClarityTracker +import org.grakovne.lissen.analytics.AnalyticsTracker import org.grakovne.lissen.common.LibraryOrderingConfiguration import org.grakovne.lissen.common.NetworkService import org.grakovne.lissen.content.BookRepository @@ -45,7 +45,7 @@ class LibraryViewModel private val bookRepository: BookRepository, private val preferences: LissenSharedPreferences, private val networkService: NetworkService, - private val clarityTracker: ClarityTracker, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val _recentBooks = MutableLiveData>(emptyList()) val recentBooks: LiveData> = _recentBooks @@ -61,6 +61,9 @@ class LibraryViewModel private val _totalCount = MutableLiveData() val totalCount: LiveData = _totalCount + private val _isInitializing = MutableStateFlow(true) + val isInitializing: StateFlow = _isInitializing + private val pageConfig = PagingConfig( pageSize = PAGE_SIZE, @@ -100,6 +103,8 @@ class LibraryViewModel currentLibraryId = preferences.getPreferredLibrary()?.id ?: "" currentOrdering = preferences.getLibraryOrdering() localCacheUpdatedAt = latestLocalUpdate ?: 0L + } else { + _isInitializing.value = false } } @@ -215,7 +220,7 @@ class LibraryViewModel fun updateSearch(token: String) { _searchToken.value = token if (token.isNotEmpty()) { - clarityTracker.trackEvent("search_performed") + analyticsTracker.trackEvent("search_performed") } } @@ -232,34 +237,38 @@ class LibraryViewModel fun refreshLibrary(forceRefresh: Boolean = false) { viewModelScope.launch { - withContext(Dispatchers.IO) { - val isAvailable = - if (forceRefresh) { - networkService.refreshServerAvailabilitySync() - } else { - networkService.isServerAvailable.value - } - - val shouldSync = (forceRefresh || isAvailable) && !preferences.isForceCache() - - if (shouldSync) { - val libraryId = preferences.getPreferredLibrary()?.id + try { + withContext(Dispatchers.IO) { + val isAvailable = + if (forceRefresh) { + networkService.refreshServerAvailabilitySync() + } else { + networkService.isServerAvailable.value + } + + val shouldSync = (forceRefresh || isAvailable) && !preferences.isForceCache() + + if (shouldSync) { + val libraryId = preferences.getPreferredLibrary()?.id + + if (libraryId != null) { + bookRepository.syncLibraryPage( + libraryId = libraryId, + pageSize = PAGE_SIZE, + pageNumber = 0, + ) + } - if (libraryId != null) { - bookRepository.syncLibraryPage( - libraryId = libraryId, - pageSize = PAGE_SIZE, - pageNumber = 0, - ) + bookRepository.syncRepositories() } - bookRepository.syncRepositories() - } - - when (searchRequested.value) { - true -> searchPagingSource?.invalidate() - else -> defaultPagingSource.value?.invalidate() + when (searchRequested.value) { + true -> searchPagingSource?.invalidate() + else -> defaultPagingSource.value?.invalidate() + } } + } finally { + _isInitializing.value = false } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt index edc01eaed..0ad6bbc24 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -7,8 +7,7 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch -import org.grakovne.lissen.analytics.ClarityComponent -import org.grakovne.lissen.analytics.ClarityTracker +import org.grakovne.lissen.analytics.AnalyticsTracker import org.grakovne.lissen.channel.audiobookshelf.Host import org.grakovne.lissen.channel.common.OperationResult import org.grakovne.lissen.common.ColorScheme @@ -37,8 +36,7 @@ class SettingsViewModel private val preferences: LissenSharedPreferences, private val bookRepository: org.grakovne.lissen.content.BookRepository, private val cachedCoverProvider: org.grakovne.lissen.content.cache.temporary.CachedCoverProvider, - private val clarityTracker: ClarityTracker, - private val clarityComponent: ClarityComponent, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val _host: MutableLiveData = MutableLiveData(preferences.getHost()?.let { Host.external(it) }) val host = _host @@ -149,7 +147,7 @@ class SettingsViewModel fun updateAnalyticsConsent(accepted: Boolean) { _analyticsConsent.postValue(accepted) preferences.saveAnalyticsConsentState(accepted) - clarityComponent.updateConsent(accepted) + analyticsTracker.updateConsent(accepted) } fun refreshConnectionInfo() { @@ -199,7 +197,7 @@ class SettingsViewModel fun preferLibrary(library: Library) { _preferredLibrary.postValue(library) preferences.savePreferredLibrary(library) - clarityTracker.trackEvent("library_switched", library.id) + analyticsTracker.trackEvent("library_switched", library.id) } fun preferAutoDownloadNetworkType(type: NetworkTypeAutoCache) { diff --git a/app/src/play/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt b/app/src/play/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt new file mode 100644 index 000000000..8c226a9fe --- /dev/null +++ b/app/src/play/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt @@ -0,0 +1,13 @@ +package org.grakovne.lissen.analytics + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface AnalyticsModule { + @Binds + fun bindAnalyticsTracker(tracker: MicrosoftClarityTracker): AnalyticsTracker +} diff --git a/app/src/play/kotlin/org/grakovne/lissen/analytics/MicrosoftClarityTracker.kt b/app/src/play/kotlin/org/grakovne/lissen/analytics/MicrosoftClarityTracker.kt new file mode 100644 index 000000000..a210dec58 --- /dev/null +++ b/app/src/play/kotlin/org/grakovne/lissen/analytics/MicrosoftClarityTracker.kt @@ -0,0 +1,79 @@ +package org.grakovne.lissen.analytics + +import android.content.Context +import com.microsoft.clarity.Clarity +import com.microsoft.clarity.ClarityConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import org.grakovne.lissen.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MicrosoftClarityTracker + @Inject + constructor( + @ApplicationContext private val context: Context, + ) : AnalyticsTracker { + private var clarityInitialized = false + private val eventQueue = mutableListOf>() + private var pendingUserId: String? = null + + override fun trackEvent( + name: String, + value: String?, + ) { + if (clarityInitialized) { + sendEventToClarity(name, value) + } else { + eventQueue.add(name to value) + } + } + + override fun setUser(id: String) { + if (clarityInitialized) { + Clarity.setCustomUserId(id) + } else { + pendingUserId = id + } + } + + override fun updateConsent(accepted: Boolean) { + if (accepted) { + if (!clarityInitialized) { + val config = + ClarityConfig( + projectId = BuildConfig.CLARITY_PROJECT_ID, + ) + Clarity.initialize(context, config) + clarityInitialized = true + flushQueues() + } + } else { + if (clarityInitialized) { + Clarity.pause() + } + } + } + + private fun sendEventToClarity( + name: String, + value: String?, + ) { + if (value == null) { + Clarity.sendCustomEvent(name) + } else { + Clarity.sendCustomEvent("$name: $value") + } + } + + private fun flushQueues() { + pendingUserId?.let { + Clarity.setCustomUserId(it) + pendingUserId = null + } + eventQueue.forEach { (name, value) -> + sendEventToClarity(name, value) + } + eventQueue.clear() + } + } diff --git a/app/src/play/kotlin/org/grakovne/lissen/common/AnalyticsExtensions.kt b/app/src/play/kotlin/org/grakovne/lissen/common/AnalyticsExtensions.kt new file mode 100644 index 000000000..472435d3a --- /dev/null +++ b/app/src/play/kotlin/org/grakovne/lissen/common/AnalyticsExtensions.kt @@ -0,0 +1,6 @@ +package org.grakovne.lissen.common + +import androidx.compose.ui.Modifier +import com.microsoft.clarity.modifiers.clarityMask + +fun Modifier.maskForAnalytics(): Modifier = this.clarityMask() diff --git a/app/src/play/kotlin/org/grakovne/lissen/common/FirebaseCrashReporter.kt b/app/src/play/kotlin/org/grakovne/lissen/common/FirebaseCrashReporter.kt new file mode 100644 index 000000000..3ba5eea25 --- /dev/null +++ b/app/src/play/kotlin/org/grakovne/lissen/common/FirebaseCrashReporter.kt @@ -0,0 +1,18 @@ +package org.grakovne.lissen.common + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FirebaseCrashReporter + @Inject + constructor() : CrashReporter { + override fun setCollectionEnabled(enabled: Boolean) { + FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(enabled) + } + + override fun recordException(exception: Throwable) { + FirebaseCrashlytics.getInstance().recordException(exception) + } + } diff --git a/app/src/play/kotlin/org/grakovne/lissen/common/ReportingModule.kt b/app/src/play/kotlin/org/grakovne/lissen/common/ReportingModule.kt new file mode 100644 index 000000000..6192a3f18 --- /dev/null +++ b/app/src/play/kotlin/org/grakovne/lissen/common/ReportingModule.kt @@ -0,0 +1,13 @@ +package org.grakovne.lissen.common + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface ReportingModule { + @Binds + fun bindCrashReporter(reporter: FirebaseCrashReporter): CrashReporter +} diff --git a/build.gradle.kts b/build.gradle.kts index 79565bc1a..3f3675adb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,21 @@ +import java.util.Properties + +// FOSS_REMOVE_START +buildscript { + dependencies { + val g = "com." + "google" + val s = "gms" + val v = "google-services:4.4.2" + classpath("$g.$s:$v") + + val f = "com." + "google" + val b = "fire" + "base" + val r = "firebase" + "-crashlytics-gradle:3.0.3" + classpath("$f.$b:$r") + } +} +// FOSS_REMOVE_END + plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false @@ -5,6 +23,4 @@ plugins { id("com.google.devtools.ksp") version "2.2.21-2.0.4" apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.google.services) apply false - alias(libs.plugins.firebase.crashlytics) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60d6775e8..9a4ce77ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ coreKtx = "1.17.0" timber = "5.0.1" moshi = "1.15.2" converterMoshi = "3.0.0" -clarity = "3.8.0" +clarity = "3.8.1" googleServices = "4.4.2" firebaseCrashlyticsPlugin = "3.0.3" firebaseBom = "33.9.0" diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt index 5c6283f0f..34cb5f818 100644 --- a/metadata/en-US/full_description.txt +++ b/metadata/en-US/full_description.txt @@ -1,3 +1,19 @@ -Listen to your favorite audiobooks through a minimalist app designed for one task +Kahani (meaning "story") is a modern, offline-first client for Audiobookshelf, designed specifically for a premium audiobook and podcast experience on Android. -Sync your listened books between devices and other Audiobookshelf players and download books to listen to them without the Internet +Built with Material Design 3 and full Dynamic Color support, Kahani feels like a native part of your device while providing powerful features for power listeners. + +✨ KEY FEATURES + +• Offline-First: Download entire books or specific chapters to listen anywhere, anytime. +• Seamless Sync: Your listening progress syncs automatically across all your devices and the server. +• Visual Excellence: Stunning Material You interface with smooth transitions and adaptive themes. +• Advanced Playback: Variable playback speed, skip silence, and customizable seek intervals. +• Chapter Support: Easily browse and navigate through chapters with precise progress tracking. +• Podcast Ready: Full support for both your audiobook collection and your favorite podcasts. +• Sleep Timer: Fall asleep without worry using the flexible sleep timer (time-based or end-of-chapter). +• Home Widget: Control your current story directly from your home screen. +• Network Aware: Intelligently manages offline/online states and server availability. + +Kahani is focused on simplicity, privacy, and the joy of a good story. + +Note: An Audiobookshelf server instance is required to use this application. diff --git a/metadata/en-US/images/featureGraphic.png b/metadata/en-US/images/featureGraphic.png deleted file mode 100644 index 450a5f96f..000000000 Binary files a/metadata/en-US/images/featureGraphic.png and /dev/null differ diff --git a/metadata/en-US/images/icon.png b/metadata/en-US/images/icon.png index a6f2b3127..017098239 100644 Binary files a/metadata/en-US/images/icon.png and b/metadata/en-US/images/icon.png differ diff --git a/metadata/en-US/short_description.txt b/metadata/en-US/short_description.txt index 601599e12..e01fd8b24 100644 --- a/metadata/en-US/short_description.txt +++ b/metadata/en-US/short_description.txt @@ -1 +1 @@ -Clean Audiobookshelf Player +Modern, offline-first audiobook & podcast client for Audiobookshelf. diff --git a/metadata/en-US/title.txt b/metadata/en-US/title.txt index 9075fd0d1..00ad61bb8 100644 --- a/metadata/en-US/title.txt +++ b/metadata/en-US/title.txt @@ -1 +1 @@ -Lissen: Audiobookshelf client +Kahani: Audiobookshelf client