diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e795263..9fc8edf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,15 +35,28 @@ android { buildTypes { debug { + isDebuggable = true enableUnitTestCoverage = true enableAndroidTestCoverage = true + // versionNameSuffix = "-release" // Optional: Adds a suffix to the version name for differentiation + buildConfigField("Boolean", "IS_DEVELOPER_MODE_ENABLED", "true") + } + create("benchmark") { + initWith(buildTypes.getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") } release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + // Configures the signing with the debug key for now so that we can run the release build + signingConfig = signingConfigs.getByName("debug") + isDebuggable = false // Disables debugging for security reasons + isShrinkResources = true // Further reduces the size of the APK by removing unused resources + buildConfigField("Boolean", "IS_DEVELOPER_MODE_ENABLED", "false") } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c22f6d..f334dd8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + + - - - - - - - - - - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/app/books/tanga/TangaApp.kt b/app/src/main/java/app/books/tanga/TangaApp.kt index fe820a5..7afbafc 100644 --- a/app/src/main/java/app/books/tanga/TangaApp.kt +++ b/app/src/main/java/app/books/tanga/TangaApp.kt @@ -1,6 +1,7 @@ package app.books.tanga import android.app.Application +import android.os.StrictMode import app.books.tanga.di.TimberTrees import app.books.tanga.di.plantAll import app.books.tanga.errors.TangaErrorTracker @@ -22,5 +23,30 @@ class TangaApp : Application() { // Initialize the error tracker errorTracker.init() + + configureStrictMode() + } + + private fun configureStrictMode() { + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectNetwork() + .penaltyLog() + .penaltyFlashScreen() + .build() + ) + + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .detectActivityLeaks() + .penaltyLog() + .build() + ) + } } } diff --git a/app/src/main/java/app/books/tanga/common/urls/DownloadUrlGenerator.kt b/app/src/main/java/app/books/tanga/common/urls/DownloadUrlGenerator.kt index b9d8ad8..6ab0916 100644 --- a/app/src/main/java/app/books/tanga/common/urls/DownloadUrlGenerator.kt +++ b/app/src/main/java/app/books/tanga/common/urls/DownloadUrlGenerator.kt @@ -1,5 +1,6 @@ package app.books.tanga.common.urls +import app.books.tanga.di.IoDispatcher import app.books.tanga.entity.SummaryId import com.google.firebase.ktx.Firebase import com.google.firebase.storage.FirebaseStorage @@ -11,7 +12,10 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext import timber.log.Timber /** @@ -35,7 +39,8 @@ interface DownloadUrlGenerator { * using Firebase Storage. */ class StorageDownloadUrlGenerator @Inject constructor( - private val storage: FirebaseStorage + private val storage: FirebaseStorage, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher ) : DownloadUrlGenerator { /** @@ -44,10 +49,12 @@ class StorageDownloadUrlGenerator @Inject constructor( */ override suspend fun generateCoverDownloadUrl( summaryId: SummaryId - ): String? = SummaryCoverUrlCache.get(summaryId) ?: getSummaryReference(summaryId).generateDownloadUrl( - path = SummaryFormatType.COVER.filename - ).also { url -> - url?.let { SummaryCoverUrlCache.put(summaryId, it) } + ): String? = withContext(ioDispatcher) { + SummaryCoverUrlCache.get(summaryId) ?: getSummaryReference(summaryId).generateDownloadUrl( + path = SummaryFormatType.COVER.filename + ).also { url -> + url?.let { SummaryCoverUrlCache.put(summaryId, it) } + } } override suspend fun generateTextDownloadUrl(summaryId: SummaryId): String? = getSummaryReference( @@ -86,7 +93,7 @@ class StorageDownloadUrlGenerator @Inject constructor( * Singleton instance of [StorageDownloadUrlGenerator] for convenience in places where when can't use Hilt DI. */ val instance: StorageDownloadUrlGenerator - get() = StorageDownloadUrlGenerator(storage = Firebase.storage) + get() = StorageDownloadUrlGenerator(storage = Firebase.storage, ioDispatcher = Dispatchers.IO) } } diff --git a/app/src/main/java/app/books/tanga/di/GoogleSignInModule.kt b/app/src/main/java/app/books/tanga/di/GoogleSignInModule.kt index 3f0797d..335a5cb 100644 --- a/app/src/main/java/app/books/tanga/di/GoogleSignInModule.kt +++ b/app/src/main/java/app/books/tanga/di/GoogleSignInModule.kt @@ -38,7 +38,7 @@ class GoogleSignInModule { .builder() .setSupported(true) .setServerClientId(clientId) - .setFilterByAuthorizedAccounts(true) + .setFilterByAuthorizedAccounts(false) .build() ).setAutoSelectEnabled(true) .build() @@ -55,7 +55,7 @@ class GoogleSignInModule { .builder() .setSupported(true) .setServerClientId(clientId) - .setFilterByAuthorizedAccounts(true) + .setFilterByAuthorizedAccounts(false) .build() ).build() diff --git a/app/src/main/java/app/books/tanga/feature/auth/AnonymousAuthService.kt b/app/src/main/java/app/books/tanga/feature/auth/AnonymousAuthService.kt index 9d928a0..784104c 100644 --- a/app/src/main/java/app/books/tanga/feature/auth/AnonymousAuthService.kt +++ b/app/src/main/java/app/books/tanga/feature/auth/AnonymousAuthService.kt @@ -29,7 +29,7 @@ class AnonymousAuthServiceImpl @Inject constructor( @Suppress("TooGenericExceptionThrown") override suspend fun signInAnonymously(): AuthResult { val result = auth.signInAnonymously().await() - val firebaseUser = result.user ?: throw Throwable("Failed to sign in anonymously") + val firebaseUser = result.user ?: throw Throwable("Anonymous sign in returned null user") val user = firebaseUser.toAnonymousUser() diff --git a/app/src/main/java/app/books/tanga/feature/auth/AuthenticationInteractor.kt b/app/src/main/java/app/books/tanga/feature/auth/AuthenticationInteractor.kt index 97908e6..83a25f7 100644 --- a/app/src/main/java/app/books/tanga/feature/auth/AuthenticationInteractor.kt +++ b/app/src/main/java/app/books/tanga/feature/auth/AuthenticationInteractor.kt @@ -33,7 +33,7 @@ class AuthenticationInteractor @Inject constructor( val authResult = anonymousAuthService.signInAnonymously() authResult.user }.onFailure { - Timber.e("Anonymous sign in failed", it) + Timber.e(it, "Anonymous sign in failed") return Result.failure(DomainError.AuthenticationError(it)) } @@ -65,6 +65,7 @@ class AuthenticationInteractor @Inject constructor( sessionManager.openSession(sessionId) user }.onFailure { + Timber.e(it, "Complete Google sign in failed") return Result.failure(DomainError.UnableToSignInWithGoogleError(it)) } diff --git a/app/src/main/java/app/books/tanga/feature/onboarding/OnboardingScreen.kt b/app/src/main/java/app/books/tanga/feature/onboarding/OnboardingScreen.kt index 59fb445..16bdfeb 100644 --- a/app/src/main/java/app/books/tanga/feature/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/app/books/tanga/feature/onboarding/OnboardingScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,6 +58,15 @@ fun OnboardingScreen( val pagerState = rememberPagerState() val scope = rememberCoroutineScope() + val onNextClick: () -> Unit = remember { + { + scope.launch { + if (pagerState.currentPage < MAX_PAGER_INDEX) { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + } + } Column( modifier = modifier @@ -85,11 +95,7 @@ fun OnboardingScreen( FinishOnboardingButton( modifier = Modifier.weight(1f), pagerState = pagerState, - onNextClick = { - scope.launch { - pagerState.animateScrollToPage(pagerState.currentPage.inc()) - } - }, + onNextClick = onNextClick, onFinishClick = { onboardingViewModel.onOnboardingCompleted() navController.popBackStack() diff --git a/app/src/test/java/app/books/tanga/StorageDownloadUrlGeneratorTest.kt b/app/src/test/java/app/books/tanga/StorageDownloadUrlGeneratorTest.kt index a493d45..cba4c20 100644 --- a/app/src/test/java/app/books/tanga/StorageDownloadUrlGeneratorTest.kt +++ b/app/src/test/java/app/books/tanga/StorageDownloadUrlGeneratorTest.kt @@ -12,13 +12,16 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +@OptIn(ExperimentalCoroutinesApi::class) class StorageDownloadUrlGeneratorTest { private lateinit var generator: StorageDownloadUrlGenerator @@ -28,6 +31,8 @@ class StorageDownloadUrlGeneratorTest { private val mockkStorageReferenceSummary = mockk() private val mockkStorageReferenceSummaryCover = mockk() + private val testDispatcher = UnconfinedTestDispatcher() + @BeforeEach fun setUp() { mockkStatic("kotlinx.coroutines.tasks.TasksKt") @@ -37,7 +42,7 @@ class StorageDownloadUrlGeneratorTest { every { mockkStorageReferenceSummary.child(any()) } returns mockkStorageReferenceSummaryCover coEvery { mockkStorageReferenceSummaryCover.downloadUrl.await().toString() } returns mockUrl - generator = StorageDownloadUrlGenerator(storage = mockStorage) + generator = StorageDownloadUrlGenerator(storage = mockStorage, testDispatcher) } @Test diff --git a/benchmark/.gitignore b/benchmark/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/benchmark/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000..9d65744 --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.androidTest) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "app.books.tanga.benchmark" + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + defaultConfig { + minSdk = 24 + targetSdk = 34 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // This benchmark buildType is used for benchmarking, and should function like your + // release build (for example, with minification on). It"s signed with a debug key + // for easy local/CI testing. + create("benchmark") { + isDebuggable = false + signingConfig = getByName("debug").signingConfig + matchingFallbacks += listOf("release") + } + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +dependencies { + implementation(libs.android.test.junit) + implementation(libs.android.espresso.core) + implementation(libs.uiautomator) + implementation(libs.benchmark.macro.junit4) +} + +androidComponents { + beforeVariants(selector().all()) { + it.enable = it.buildType == "benchmark" + } +} diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000..227314e --- /dev/null +++ b/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmark/src/main/java/app/books/tanga/benchmark/ExampleStartupBenchmark.kt b/benchmark/src/main/java/app/books/tanga/benchmark/ExampleStartupBenchmark.kt new file mode 100644 index 0000000..a4ad103 --- /dev/null +++ b/benchmark/src/main/java/app/books/tanga/benchmark/ExampleStartupBenchmark.kt @@ -0,0 +1,38 @@ +package app.books.tanga.benchmark + +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * This is an example startup benchmark. + * + * It navigates to the device's home screen, and launches the default activity. + * + * Before running this benchmark: + * 1) switch your app's active build variant in the Studio (affects Studio runs only) + * 2) add `` to your app's manifest, within the `` tag + * + * Run this benchmark from Studio to see startup measurements, and captured system traces + * for investigating your app's performance. + */ +@RunWith(AndroidJUnit4::class) +class ExampleStartupBenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startup() = benchmarkRule.measureRepeated( + packageName = "app.books.tanga", + metrics = listOf(StartupTimingMetric()), + iterations = 5, + startupMode = StartupMode.COLD + ) { + pressHome() + startActivityAndWait() + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 14fc373..ca33060 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,7 @@ plugins { alias(libs.plugins.detekt) apply false alias(libs.plugins.junit5) apply false alias(libs.plugins.kover) apply false + alias(libs.plugins.androidTest) apply false } subprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c5ebac..0b2d110 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ hilt-version = "2.47" # Firebase and Data # https://firebase.google.com/support/release-notes/android -firebase-bom-version = "31.2.0" +firebase-bom-version = "32.7.0" # https://developer.android.com/jetpack/androidx/releases/datastore datastore-version = "1.0.0" @@ -67,6 +67,11 @@ mockk-version = "1.13.8" mockito-android-version = "5.7.0" mockito-kotlin-version = "5.1.0" +# Performance Testing +uiautomator = "2.2.0" +benchmark-macro-junit4 = "1.2.2" + + # Google Services # google-services-version = "4.3.15" @@ -167,6 +172,8 @@ android-test-junit = { module = "androidx.test.ext:junit", version.ref = "androi android-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-version" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose-version" } navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigation-testing-version" } +uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark-macro-junit4" } # Lint Checks lint-api = { module = "com.android.tools.lint:lint-api", version.ref = "lint-api" } @@ -189,3 +196,4 @@ ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-version" detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt-version" } junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "junit5-version" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover-version" } +androidTest = { id = "com.android.test", version.ref = "android-plugin-gradle-version" } diff --git a/settings.gradle.kts b/settings.gradle.kts index b6f5b7e..39329b4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,4 @@ rootProject.name = "Tanga" include(":app") include(":core-ui") include(":code-checks") +include(":benchmark")