Merge remote-tracking branch 'origin/main' into move-android-instrumented-test

pull/1048/head
Simon Marquis 9 months ago
commit 8d89a89096

@ -45,13 +45,40 @@ jobs:
- name: Check spotless - name: Check spotless
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
- name: Check Dependency Guard
id: dependencyguard_verify
continue-on-error: true
run: ./gradlew dependencyGuard
- name: Prevent updating Dependency Guard baselines if this is a fork
id: checkfork_dependencyguard
continue-on-error: false
if: steps.dependencyguard_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::Dependency Guard failed, please update baselines with: ./gradlew dependencyGuardBaseline" && exit 1
# Runs if previous job failed
- name: Generate new Dependency Guard baselines if verification failed and it's a PR
id: dependencyguard_baseline
if: steps.dependencyguard_verify.outcome == 'failure' && github.event_name == 'pull_request'
run: |
./gradlew dependencyGuardBaseline
- name: Push new Dependency Guard baselines if available
uses: stefanzweifel/git-auto-commit-action@v5
if: steps.dependencyguard_baseline.outcome == 'success'
with:
file_pattern: '**/dependencies/*.txt'
disable_globbing: true
commit_message: "🤖 Updates baselines for Dependency Guard"
- name: Run all local screenshot tests (Roborazzi) - name: Run all local screenshot tests (Roborazzi)
id: screenshotsverify id: screenshotsverify
continue-on-error: true continue-on-error: true
run: ./gradlew verifyRoborazziDemoDebug run: ./gradlew verifyRoborazziDemoDebug
- name: Prevent pushing new screenshots if this is a fork - name: Prevent pushing new screenshots if this is a fork
id: checkfork id: checkfork_screenshots
continue-on-error: false continue-on-error: false
if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: | run: |
@ -82,13 +109,9 @@ jobs:
- name: Build all build type and flavor permutations - name: Build all build type and flavor permutations
run: ./gradlew :app:assemble :benchmarks:assemble run: ./gradlew :app:assemble :benchmarks:assemble
-x pixel6Api33ProdNonMinifiedReleaseAndroidTest -x pixel6Api33ProdNonMinifiedReleaseAndroidTest
-x pixel6Api33ProdNonMinifiedBenchmarkAndroidTest
-x pixel6Api33DemoNonMinifiedReleaseAndroidTest -x pixel6Api33DemoNonMinifiedReleaseAndroidTest
-x pixel6Api33DemoNonMinifiedBenchmarkAndroidTest
-x collectDemoNonMinifiedReleaseBaselineProfile -x collectDemoNonMinifiedReleaseBaselineProfile
-x collectDemoNonMinifiedBenchmarkBaselineProfile
-x collectProdNonMinifiedReleaseBaselineProfile -x collectProdNonMinifiedReleaseBaselineProfile
-x collectProdNonMinifiedBenchmarkBaselineProfile
- name: Upload build outputs (APKs) - name: Upload build outputs (APKs)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -117,13 +140,31 @@ jobs:
run: ./gradlew :app:checkProdReleaseBadging run: ./gradlew :app:checkProdReleaseBadging
androidTest: androidTest:
runs-on: macOS-latest # enables hardware acceleration in the virtual machine runs-on: ubuntu-latest
timeout-minutes: 55 timeout-minutes: 55
strategy: strategy:
matrix: matrix:
api-level: [26, 30] api-level: [26, 30]
steps: steps:
- name: Delete unnecessary tools 🔧
uses: jlumbroso/free-disk-space@v1.3.1
with:
android: false # Don't remove Android tools
tool-cache: true # Remove image tool cache - rm -rf "$AGENT_TOOLSDIRECTORY"
dotnet: true # rm -rf /usr/share/dotnet
haskell: true # rm -rf /opt/ghc...
swap-storage: true # rm -f /mnt/swapfile (4GiB)
docker-images: false # Takes 16s, enable if needed in the future
large-packages: false # includes google-cloud-sdk and it's slow
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

@ -7,10 +7,17 @@ on:
jobs: jobs:
build: build:
runs-on: macos-latest runs-on: ubuntu-latest
timeout-minutes: 120 timeout-minutes: 120
steps: steps:
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

@ -70,7 +70,8 @@ androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1 androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1 androidx.savedstate:savedstate:1.2.1
androidx.startup:startup-runtime:1.1.1 androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing:1.0.0 androidx.tracing:tracing-ktx:1.3.0-alpha02
androidx.tracing:tracing:1.3.0-alpha02
androidx.vectordrawable:vectordrawable-animated:1.1.0 androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0 androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1 androidx.versionedparcelable:versionedparcelable:1.1.1

@ -57,17 +57,6 @@ android {
// Ensure Baseline Profile is fresh for release builds. // Ensure Baseline Profile is fresh for release builds.
baselineProfile.automaticGenerationDuringBuild = true baselineProfile.automaticGenerationDuringBuild = true
} }
create("benchmark") {
// Enable all the optimizations from release build through initWith(release).
initWith(release)
matchingFallbacks.add("release")
// Debug key signing is available to everyone.
signingConfig = signingConfigs.getByName("debug")
// Only use benchmark proguard rules
proguardFiles("benchmark-rules.pro")
isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.BENCHMARK.applicationIdSuffix
}
} }
packaging { packaging {

@ -71,19 +71,20 @@ androidx.hilt:hilt-navigation:1.0.0
androidx.hilt:hilt-work:1.1.0 androidx.hilt:hilt-work:1.1.0
androidx.interpolator:interpolator:1.0.0 androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.6.2 androidx.lifecycle:lifecycle-common-java8:2.7.0
androidx.lifecycle:lifecycle-common:2.6.2 androidx.lifecycle:lifecycle-common:2.7.0
androidx.lifecycle:lifecycle-livedata-core:2.6.2 androidx.lifecycle:lifecycle-livedata-core-ktx:2.7.0
androidx.lifecycle:lifecycle-livedata:2.6.2 androidx.lifecycle:lifecycle-livedata-core:2.7.0
androidx.lifecycle:lifecycle-process:2.6.2 androidx.lifecycle:lifecycle-livedata:2.7.0
androidx.lifecycle:lifecycle-runtime-compose:2.6.2 androidx.lifecycle:lifecycle-process:2.7.0
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2 androidx.lifecycle:lifecycle-runtime-compose:2.7.0
androidx.lifecycle:lifecycle-runtime:2.6.2 androidx.lifecycle:lifecycle-runtime-ktx:2.7.0
androidx.lifecycle:lifecycle-service:2.6.2 androidx.lifecycle:lifecycle-runtime:2.7.0
androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2 androidx.lifecycle:lifecycle-service:2.7.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2 androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2 androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0
androidx.lifecycle:lifecycle-viewmodel:2.6.2 androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0
androidx.lifecycle:lifecycle-viewmodel:2.7.0
androidx.loader:loader:1.0.0 androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04 androidx.metrics:metrics-performance:1.0.0-alpha04
@ -105,15 +106,15 @@ androidx.savedstate:savedstate:1.2.1
androidx.sqlite:sqlite-framework:2.4.0 androidx.sqlite:sqlite-framework:2.4.0
androidx.sqlite:sqlite:2.4.0 androidx.sqlite:sqlite:2.4.0
androidx.startup:startup-runtime:1.1.1 androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.1.0 androidx.tracing:tracing-ktx:1.3.0-alpha02
androidx.tracing:tracing:1.1.0 androidx.tracing:tracing:1.3.0-alpha02
androidx.vectordrawable:vectordrawable-animated:1.1.0 androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0 androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1 androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0 androidx.viewpager:viewpager:1.0.0
androidx.window:window:1.0.0 androidx.window:window:1.0.0
androidx.work:work-runtime-ktx:2.9.0-rc01 androidx.work:work-runtime-ktx:2.9.0
androidx.work:work-runtime:2.9.0-rc01 androidx.work:work-runtime:2.9.0
com.caverock:androidsvg-aar:1.4 com.caverock:androidsvg-aar:1.4
com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.accompanist:accompanist-permissions:0.32.0 com.google.accompanist:accompanist-permissions:0.32.0
@ -168,8 +169,8 @@ com.google.guava:failureaccess:1.0.1
com.google.guava:guava:31.1-android com.google.guava:guava:31.1-android
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:1.3 com.google.j2objc:j2objc-annotations:1.3
com.google.protobuf:protobuf-javalite:3.24.4 com.google.protobuf:protobuf-javalite:3.25.2
com.google.protobuf:protobuf-kotlin-lite:3.24.4 com.google.protobuf:protobuf-kotlin-lite:3.25.2
com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0 com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0
com.squareup.okhttp3:logging-interceptor:4.12.0 com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okhttp3:okhttp:4.12.0

@ -90,7 +90,7 @@ class NavigationTest {
lateinit var topicsRepository: TopicsRepository lateinit var topicsRepository: TopicsRepository
private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
ReadOnlyProperty<Any?, String> { _, _ -> activity.getString(resId) } ReadOnlyProperty<Any, String> { _, _ -> activity.getString(resId) }
// The strings used for matching in these tests // The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up) private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up)

@ -0,0 +1,70 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.util
import android.util.Log
import androidx.profileinstaller.ProfileVerifier
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].
*
* When delivering through Google Play, the baseline profile is compiled during installation.
* In this case you will see the correct state logged without any further action necessary.
* To verify baseline profile installation locally, you need to manually trigger baseline
* profile installation.
*
* For immediate compilation, call:
* ```bash
* adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target
* ```
* You can also trigger background optimizations:
* ```bash
* adb shell pm bg-dexopt-job
* ```
* Both jobs run asynchronously and might take some time complete.
*
* To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
* If you don't do either of these steps, you might only see the profile status reported as
* "enqueued for compilation" when running the sample locally.
*
* @see androidx.profileinstaller.ProfileVerifier.CompilationStatus.ResultCode
*/
class ProfileVerifierLogger @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
) {
companion object {
private const val TAG = "ProfileInstaller"
}
operator fun invoke() = scope.launch {
val status = ProfileVerifier.getCompilationStatusAsync().await()
Log.d(TAG, "Status code: ${status.profileInstallResultCode}")
Log.d(
TAG,
when {
status.isCompiledWithProfile -> "App compiled with profile"
status.hasProfileEnqueuedForCompilation() -> "Profile enqueued for compilation"
else -> "Profile not compiled nor enqueued"
},
)
}
}

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid package com.google.samples.apps.nowinandroid
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@ -37,7 +36,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.metrics.performance.JankStats import androidx.metrics.performance.JankStats
import androidx.profileinstaller.ProfileVerifier
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
@ -49,12 +47,9 @@ import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
@ -90,9 +85,7 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState viewModel.uiState
.onEach { .onEach { uiState = it }
uiState = it
}
.collect() .collect()
} }
} }
@ -152,48 +145,12 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
lazyStats.get().isTrackingEnabled = true lazyStats.get().isTrackingEnabled = true
lifecycleScope.launch {
logCompilationStatus()
}
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
lazyStats.get().isTrackingEnabled = false lazyStats.get().isTrackingEnabled = false
} }
/**
* Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].
*/
private suspend fun logCompilationStatus() {
/*
When delivering through Google Play, the baseline profile is compiled during installation.
In this case you will see the correct state logged without any further action necessary.
To verify baseline profile installation locally, you need to manually trigger baseline
profile installation.
For immediate compilation, call:
`adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target`
You can also trigger background optimizations:
`adb shell pm bg-dexopt-job`
Both jobs run asynchronously and might take some time complete.
To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
If you don't do either of these steps, you might only see the profile status reported as
"enqueued for compilation" when running the sample locally.
*/
withContext(Dispatchers.IO) {
val status = ProfileVerifier.getCompilationStatusAsync().await()
Log.d(TAG, "ProfileInstaller status code: ${status.profileInstallResultCode}")
Log.d(
TAG,
when {
status.isCompiledWithProfile -> "ProfileInstaller: is compiled with profile"
status.hasProfileEnqueuedForCompilation() ->
"ProfileInstaller: Enqueued for compilation"
else -> "Profile not compiled or enqueued"
},
)
}
}
} }
/** /**

@ -20,9 +20,9 @@ import android.app.Application
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import com.google.samples.apps.nowinandroid.sync.initializers.Sync import com.google.samples.apps.nowinandroid.sync.initializers.Sync
import com.google.samples.apps.nowinandroid.util.ProfileVerifierLogger
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
/** /**
* [Application] class for NiA * [Application] class for NiA
@ -30,12 +30,16 @@ import javax.inject.Provider
@HiltAndroidApp @HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory { class NiaApplication : Application(), ImageLoaderFactory {
@Inject @Inject
lateinit var imageLoader: Provider<ImageLoader> lateinit var imageLoader: dagger.Lazy<ImageLoader>
@Inject
lateinit var profileVerifierLogger: ProfileVerifierLogger
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date. // Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this) Sync.initialize(context = this)
profileVerifierLogger()
} }
override fun newImageLoader(): ImageLoader = imageLoader.get() override fun newImageLoader(): ImageLoader = imageLoader.get()

@ -20,6 +20,7 @@ import android.app.Activity
import android.util.Log import android.util.Log
import android.view.Window import android.view.Window
import androidx.metrics.performance.JankStats import androidx.metrics.performance.JankStats
import androidx.metrics.performance.JankStats.OnFrameListener
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -29,26 +30,20 @@ import dagger.hilt.android.components.ActivityComponent
@InstallIn(ActivityComponent::class) @InstallIn(ActivityComponent::class)
object JankStatsModule { object JankStatsModule {
@Provides @Provides
fun providesOnFrameListener(): JankStats.OnFrameListener { fun providesOnFrameListener(): OnFrameListener = OnFrameListener { frameData ->
return JankStats.OnFrameListener { frameData -> // Make sure to only log janky frames.
// Make sure to only log janky frames. if (frameData.isJank) {
if (frameData.isJank) { // We're currently logging this but would better report it to a backend.
// We're currently logging this but would better report it to a backend. Log.v("NiA Jank", frameData.toString())
Log.v("NiA Jank", frameData.toString())
}
} }
} }
@Provides @Provides
fun providesWindow(activity: Activity): Window { fun providesWindow(activity: Activity): Window = activity.window
return activity.window
}
@Provides @Provides
fun providesJankStats( fun providesJankStats(
window: Window, window: Window,
frameListener: JankStats.OnFrameListener, frameListener: OnFrameListener,
): JankStats { ): JankStats = JankStats.createAndTrack(window, frameListener)
return JankStats.createAndTrack(window, frameListener)
}
} }

@ -97,9 +97,7 @@ fun NiaApp(
) { ) {
val shouldShowGradientBackground = val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
var showSettingsDialog by rememberSaveable { var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
mutableStateOf(false)
}
NiaBackground { NiaBackground {
NiaGradientBackground( NiaGradientBackground(

@ -164,9 +164,7 @@ class NiaAppState(
} }
} }
fun navigateToSearch() { fun navigateToSearch() = navController.navigateToSearch()
navController.navigateToSearch()
}
} }
/** /**

@ -20,10 +20,10 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <path
android:pathData="M65.08,84.13C64.01,84.13 63.13,83.26 63.13,82.18C63.13,81.11 64,80.24 65.08,80.24C66.15,80.24 67.02,81.11 67.02,82.18C67.02,83.26 66.15,84.13 65.08,84.13ZM43.6,84.13C42.53,84.13 41.65,83.26 41.65,82.18C41.65,81.11 42.52,80.24 43.6,80.24C44.66,80.24 45.54,81.11 45.54,82.18C45.54,83.26 44.67,84.13 43.6,84.13ZM65.77,72.44L69.66,65.73C69.88,65.35 69.74,64.85 69.36,64.63C68.97,64.41 68.48,64.54 68.25,64.93L64.32,71.73C61.31,70.36 57.94,69.59 54.33,69.59C50.73,69.59 47.35,70.36 44.34,71.73L40.41,64.93C40.19,64.54 39.69,64.41 39.31,64.63C38.92,64.85 38.79,65.35 39.01,65.73L42.89,72.44C36.22,76.07 31.67,82.81 31,90.77H77.67C77,82.8 72.44,76.06 65.77,72.44Z" android:pathData="M65.08,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM43.6,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM65.77,72.44 L69.66,65.73a0.81,0.81 0,0 0,-0.3 -1.1,0.82 0.82,0 0,0 -1.11,0.3l-3.93,6.8a24,24 0,0 0,-9.99 -2.14c-3.6,0 -6.98,0.77 -9.99,2.14l-3.93,-6.8a0.8,0.8 0,1 0,-1.4 0.8l3.88,6.71A22.91,22.91 0,0 0,31 90.77h46.67a22.9,22.9 0,0 0,-11.9 -18.33Z"
android:fillColor="@color/ic_launcher_foreground_tint"/> android:fillColor="@color/ic_launcher_foreground_tint"/>
<path <path
android:pathData="M46.57,35H46.57C46.1,35 45.72,35.38 45.72,35.85L45.72,43.15H44.19C43.35,43.15 42.67,43.83 42.67,44.68C42.67,45.52 43.35,46.2 44.19,46.2H45.72V43.15H47.42C48.17,43.15 48.78,42.54 48.78,41.79L48.78,37.72H49.97C50.43,37.72 50.81,37.34 50.81,36.87V35.85C50.81,35.38 50.43,35 49.97,35H47.42H46.57ZM46.57,54.35H46.57H47.42H49.97C50.43,54.35 50.81,53.97 50.81,53.5V52.48C50.81,52.02 50.43,51.64 49.97,51.64H48.78L48.78,47.56C48.78,46.81 48.17,46.2 47.42,46.2H45.72L45.72,53.5C45.72,53.97 46.1,54.35 46.57,54.35ZM61.54,35H61.54C62.01,35 62.39,35.38 62.39,35.85V43.15H63.92C64.76,43.15 65.44,43.83 65.44,44.68C65.44,45.52 64.76,46.2 63.92,46.2H62.39V43.15H60.69C59.94,43.15 59.33,42.54 59.33,41.79V37.72H58.15C57.68,37.72 57.3,37.34 57.3,36.87V35.85C57.3,35.38 57.68,35 58.15,35H60.69H61.54ZM61.54,54.35H61.54H60.69H58.15C57.68,54.35 57.3,53.97 57.3,53.5V52.48C57.3,52.02 57.68,51.64 58.15,51.64H59.33V47.56C59.33,46.81 59.94,46.2 60.69,46.2H62.39V53.5C62.39,53.97 62.01,54.35 61.54,54.35Z" android:pathData="M46.57,35a0.85,0.85 0,0 0,-0.85 0.85v7.3h-1.53a1.52,1.52 0,0 0,0 3.05h1.53v-3.05h1.7c0.75,0 1.36,-0.61 1.36,-1.36v-4.07h1.19c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.85h-3.4ZM46.57,54.35h3.4c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.84h-1.19v-4.08c0,-0.75 -0.61,-1.36 -1.36,-1.36h-1.7v7.3c0,0.47 0.38,0.85 0.85,0.85ZM61.54,35c0.47,0 0.85,0.38 0.85,0.85v7.3h1.53a1.52,1.52 0,0 1,0 3.05h-1.53v-3.05h-1.7c-0.75,0 -1.36,-0.61 -1.36,-1.36v-4.07h-1.18a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.47 0.38,-0.85 0.85,-0.85h3.39ZM61.54,54.35h-3.39a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.46 0.38,-0.84 0.85,-0.84h1.18v-4.08c0,-0.75 0.61,-1.36 1.36,-1.36h1.7v7.3c0,0.47 -0.38,0.85 -0.85,0.85Z"
android:fillColor="@color/ic_launcher_foreground_tint" android:fillColor="@color/ic_launcher_foreground_tint"
android:fillType="evenOdd"/> android:fillType="evenOdd"/>
</vector> </vector>

@ -24,11 +24,11 @@
android:pathData="M0,0h108v108h-108z" android:pathData="M0,0h108v108h-108z"
android:fillColor="@color/ic_launcher_background_tint"/> android:fillColor="@color/ic_launcher_background_tint"/>
<path <path
android:pathData="M65.08,84.13C64.01,84.13 63.13,83.26 63.13,82.18C63.13,81.11 64,80.24 65.08,80.24C66.15,80.24 67.02,81.11 67.02,82.18C67.02,83.26 66.15,84.13 65.08,84.13ZM43.6,84.13C42.53,84.13 41.65,83.26 41.65,82.18C41.65,81.11 42.52,80.24 43.6,80.24C44.66,80.24 45.54,81.11 45.54,82.18C45.54,83.26 44.67,84.13 43.6,84.13ZM65.77,72.44L69.66,65.73C69.88,65.35 69.74,64.85 69.36,64.63C68.97,64.41 68.48,64.54 68.25,64.93L64.32,71.73C61.31,70.36 57.94,69.59 54.33,69.59C50.73,69.59 47.35,70.36 44.34,71.73L40.41,64.93C40.19,64.54 39.69,64.41 39.31,64.63C38.92,64.85 38.79,65.35 39.01,65.73L42.89,72.44C36.22,76.07 31.67,82.81 31,90.77H77.67C77,82.8 72.44,76.06 65.77,72.44Z" android:pathData="M65.08,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM43.6,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM65.77,72.44 L69.66,65.73a0.81,0.81 0,0 0,-0.3 -1.1,0.82 0.82,0 0,0 -1.11,0.3l-3.93,6.8a24,24 0,0 0,-9.99 -2.14c-3.6,0 -6.98,0.77 -9.99,2.14l-3.93,-6.8a0.8,0.8 0,1 0,-1.4 0.8l3.88,6.71A22.91,22.91 0,0 0,31 90.77h46.67a22.9,22.9 0,0 0,-11.9 -18.33Z"
android:fillColor="@color/ic_launcher_foreground_tint"/> android:fillColor="@color/ic_launcher_foreground_tint"/>
<path <path
android:pathData="M46.57,35H46.57C46.1,35 45.72,35.38 45.72,35.85L45.72,43.15H44.19C43.35,43.15 42.67,43.83 42.67,44.68C42.67,45.52 43.35,46.2 44.19,46.2H45.72V43.15H47.42C48.17,43.15 48.78,42.54 48.78,41.79L48.78,37.72H49.97C50.43,37.72 50.81,37.34 50.81,36.87V35.85C50.81,35.38 50.43,35 49.97,35H47.42H46.57ZM46.57,54.35H46.57H47.42H49.97C50.43,54.35 50.81,53.97 50.81,53.5V52.48C50.81,52.02 50.43,51.64 49.97,51.64H48.78L48.78,47.56C48.78,46.81 48.17,46.2 47.42,46.2H45.72L45.72,53.5C45.72,53.97 46.1,54.35 46.57,54.35ZM61.54,35H61.54C62.01,35 62.39,35.38 62.39,35.85V43.15H63.92C64.76,43.15 65.44,43.83 65.44,44.68C65.44,45.52 64.76,46.2 63.92,46.2H62.39V43.15H60.69C59.94,43.15 59.33,42.54 59.33,41.79V37.72H58.15C57.68,37.72 57.3,37.34 57.3,36.87V35.85C57.3,35.38 57.68,35 58.15,35H60.69H61.54ZM61.54,54.35H61.54H60.69H58.15C57.68,54.35 57.3,53.97 57.3,53.5V52.48C57.3,52.02 57.68,51.64 58.15,51.64H59.33V47.56C59.33,46.81 59.94,46.2 60.69,46.2H62.39V53.5C62.39,53.97 62.01,54.35 61.54,54.35Z" android:pathData="M46.57,35a0.85,0.85 0,0 0,-0.85 0.85v7.3h-1.53a1.52,1.52 0,0 0,0 3.05h1.53v-3.05h1.7c0.75,0 1.36,-0.61 1.36,-1.36v-4.07h1.19c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.85h-3.4ZM46.57,54.35h3.4c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.84h-1.19v-4.08c0,-0.75 -0.61,-1.36 -1.36,-1.36h-1.7v7.3c0,0.47 0.38,0.85 0.85,0.85ZM61.54,35c0.47,0 0.85,0.38 0.85,0.85v7.3h1.53a1.52,1.52 0,0 1,0 3.05h-1.53v-3.05h-1.7c-0.75,0 -1.36,-0.61 -1.36,-1.36v-4.07h-1.18a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.47 0.38,-0.85 0.85,-0.85h3.39ZM61.54,54.35h-3.39a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.46 0.38,-0.84 0.85,-0.84h1.18v-4.08c0,-0.75 0.61,-1.36 1.36,-1.36h1.7v7.3c0,0.47 -0.38,0.85 -0.85,0.85Z"
android:fillColor="@color/ic_launcher_foreground_tint" android:fillColor="@color/ic_launcher_foreground_tint"
android:fillType="evenOdd"/> android:fillType="evenOdd"/>
</vector> </vector>

@ -37,6 +37,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
@ -140,13 +141,15 @@ class NiaAppScreenSizesScreenshotTests {
) { ) {
TestHarness(size = DpSize(width, height)) { TestHarness(size = DpSize(width, height)) {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaTheme {
windowSizeClass = WindowSizeClass.calculateFromSize( NiaApp(
DpSize(maxWidth, maxHeight), windowSizeClass = WindowSizeClass.calculateFromSize(
), DpSize(maxWidth, maxHeight),
networkMonitor = networkMonitor, ),
userNewsResourceRepository = userNewsResourceRepository, networkMonitor = networkMonitor,
) userNewsResourceRepository = userNewsResourceRepository,
)
}
} }
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 103 KiB

@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import com.google.samples.apps.nowinandroid.NiaBuildType
import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureFlavors
plugins { plugins {
@ -35,23 +34,6 @@ android {
buildConfig = true buildConfig = true
} }
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") {
// Keep the build type debuggable so we can attach a debugger if needed.
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks.add("release")
buildConfigField(
"String",
"APP_BUILD_TYPE_SUFFIX",
"\"${NiaBuildType.BENCHMARK.applicationIdSuffix ?: ""}\""
)
}
}
// Use the same flavor dimensions as the application to allow generating Baseline Profiles on prod, // Use the same flavor dimensions as the application to allow generating Baseline Profiles on prod,
// which is more close to what will be shipped to users (no fake data), but has ability to run the // which is more close to what will be shipped to users (no fake data), but has ability to run the
// benchmarks on demo, so we benchmark on stable data. // benchmarks on demo, so we benchmark on stable data.

@ -29,15 +29,11 @@ import androidx.test.uiautomator.HasChildrenOp.EXACTLY
fun untilHasChildren( fun untilHasChildren(
childCount: Int = 1, childCount: Int = 1,
op: HasChildrenOp = AT_LEAST, op: HasChildrenOp = AT_LEAST,
): UiObject2Condition<Boolean> { ): UiObject2Condition<Boolean> = object : UiObject2Condition<Boolean>() {
return object : UiObject2Condition<Boolean>() { override fun apply(element: UiObject2): Boolean = when (op) {
override fun apply(element: UiObject2): Boolean { AT_LEAST -> element.childCount >= childCount
return when (op) { EXACTLY -> element.childCount == childCount
AT_LEAST -> element.childCount >= childCount AT_MOST -> element.childCount <= childCount
EXACTLY -> element.childCount == childCount
AT_MOST -> element.childCount <= childCount
}
}
} }
} }

@ -30,7 +30,6 @@ import java.io.ByteArrayOutputStream
val PACKAGE_NAME = buildString { val PACKAGE_NAME = buildString {
append("com.google.samples.apps.nowinandroid") append("com.google.samples.apps.nowinandroid")
append(BuildConfig.APP_FLAVOR_SUFFIX) append(BuildConfig.APP_FLAVOR_SUFFIX)
append(BuildConfig.APP_BUILD_TYPE_SUFFIX)
} }
fun UiDevice.flingElementDownUp(element: UiObject2) { fun UiDevice.flingElementDownUp(element: UiObject2) {

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.baselineprofile package com.google.samples.apps.nowinandroid.baselineprofile
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
@ -30,11 +31,9 @@ class StartupBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule() @get:Rule val baselineProfileRule = BaselineProfileRule()
@Test @Test
fun generate() = fun generate() = baselineProfileRule.collect(
baselineProfileRule.collect( PACKAGE_NAME,
PACKAGE_NAME, includeInStartupProfile = true,
includeInStartupProfile = true, profileBlock = MacrobenchmarkScope::startActivityAndAllowNotifications,
) { )
startActivityAndAllowNotifications()
}
} }

@ -44,6 +44,9 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get()) add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get()) add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get()) add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
} }
} }
} }

@ -21,6 +21,7 @@ import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask import com.google.samples.apps.nowinandroid.configurePrintApksTask
import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@ -51,6 +52,8 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
} }
dependencies { dependencies {
add("testImplementation", kotlin("test")) add("testImplementation", kotlin("test"))
add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
} }
} }
} }

@ -22,5 +22,4 @@ package com.google.samples.apps.nowinandroid
enum class NiaBuildType(val applicationIdSuffix: String? = null) { enum class NiaBuildType(val applicationIdSuffix: String? = null) {
DEBUG(".debug"), DEBUG(".debug"),
RELEASE, RELEASE,
BENCHMARK(".benchmark")
} }

@ -86,14 +86,12 @@ internal abstract class PrintApkLocationTask : DefaultTask() {
fun taskAction() { fun taskAction() {
val hasFiles = sources.orNull?.any { directory -> val hasFiles = sources.orNull?.any { directory ->
directory.asFileTree.files.any { directory.asFileTree.files.any {
it.isFile && it.parentFile.path.contains("build${File.separator}generated").not() it.isFile && "build${File.separator}generated" !in it.parentFile.path
} }
} ?: throw RuntimeException("Cannot check androidTest sources") } ?: throw RuntimeException("Cannot check androidTest sources")
// Don't print APK location if there are no androidTest source files // Don't print APK location if there are no androidTest source files
if (!hasFiles) { if (!hasFiles) return
return
}
val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get()) val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get())
?: throw RuntimeException("Cannot load APKs") ?: throw RuntimeException("Cannot load APKs")

@ -23,15 +23,10 @@ import kotlinx.coroutines.flow.onStart
sealed interface Result<out T> { sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T> data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing> data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing> data object Loading : Result<Nothing>
} }
fun <T> Flow<T>.asResult(): Flow<Result<T>> { fun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }
return this .onStart { emit(Result.Loading) }
.map<T, Result<T>> { .catch { emit(Result.Error(it)) }
Result.Success(it)
}
.onStart { emit(Result.Loading) }
.catch { emit(Result.Error(it)) }
}

@ -39,9 +39,7 @@ internal class DefaultRecentSearchRepository @Inject constructor(
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> = override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries -> recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries ->
searchQueries.map { searchQueries.map { it.asExternalModel() }
it.asExternalModel()
}
} }
override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries()

@ -26,10 +26,10 @@ import javax.inject.Inject
* Fake implementation of the [RecentSearchRepository] * Fake implementation of the [RecentSearchRepository]
*/ */
class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { /* no-op */ } override suspend fun insertOrReplaceRecentSearch(searchQuery: String) = Unit
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> = override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
flowOf(emptyList()) flowOf(emptyList())
override suspend fun clearRecentSearches() { /* no-op */ } override suspend fun clearRecentSearches() = Unit
} }

@ -27,7 +27,7 @@ import javax.inject.Inject
*/ */
class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
override suspend fun populateFtsData() { /* no-op */ } override suspend fun populateFtsData() = Unit
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf() override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()
override fun getSearchContentsCount(): Flow<Int> = flowOf(1) override fun getSearchContentsCount(): Flow<Int> = flowOf(1)
} }

@ -55,9 +55,8 @@ class FakeTopicsRepository @Inject constructor(
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)
override fun getTopic(id: String): Flow<Topic> { override fun getTopic(id: String): Flow<Topic> = getTopics()
return getTopics().map { it.first { topic -> topic.id == id } } .map { it.first { topic -> topic.id == id } }
}
override suspend fun syncWith(synchronizer: Synchronizer) = true override suspend fun syncWith(synchronizer: Synchronizer) = true
} }

@ -82,7 +82,7 @@ class CompositeUserNewsResourceRepositoryTest {
// Check that only news resources with the given topic id are returned. // Check that only news resources with the given topic id are returned.
assertEquals( assertEquals(
sampleNewsResources sampleNewsResources
.filter { it.topics.contains(sampleTopic1) } .filter { sampleTopic1 in it.topics }
.mapToUserNewsResources(emptyUserData), .mapToUserNewsResources(emptyUserData),
userNewsResources.first(), userNewsResources.first(),
) )
@ -104,7 +104,7 @@ class CompositeUserNewsResourceRepositoryTest {
// Check that only news resources with the given topic id are returned. // Check that only news resources with the given topic id are returned.
assertEquals( assertEquals(
sampleNewsResources sampleNewsResources
.filter { it.topics.contains(sampleTopic1) } .filter { sampleTopic1 in it.topics }
.mapToUserNewsResources(userData), .mapToUserNewsResources(userData),
userNewsResources.first(), userNewsResources.first(),
) )

@ -91,14 +91,14 @@ class UserNewsResourceTest {
// Construct the expected FollowableTopic. // Construct the expected FollowableTopic.
val followableTopic = FollowableTopic( val followableTopic = FollowableTopic(
topic = topic, topic = topic,
isFollowed = userData.followedTopics.contains(topic.id), isFollowed = topic.id in userData.followedTopics,
) )
assertTrue(userNewsResource.followableTopics.contains(followableTopic)) assertTrue(userNewsResource.followableTopics.contains(followableTopic))
} }
// Check that the saved flag is set correctly. // Check that the saved flag is set correctly.
assertEquals( assertEquals(
userData.bookmarkedNewsResources.contains(newsResource1.id), newsResource1.id in userData.bookmarkedNewsResources,
userNewsResource.isSaved, userNewsResource.isSaved,
) )
} }

@ -34,9 +34,7 @@ val nonPresentInterestsIds = setOf("2")
*/ */
class TestNewsResourceDao : NewsResourceDao { class TestNewsResourceDao : NewsResourceDao {
private var entitiesStateFlow = MutableStateFlow( private val entitiesStateFlow = MutableStateFlow(emptyList<NewsResourceEntity>())
emptyList<NewsResourceEntity>(),
)
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf() internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
@ -131,7 +129,7 @@ class TestNewsResourceDao : NewsResourceDao {
override suspend fun deleteNewsResources(ids: List<String>) { override suspend fun deleteNewsResources(ids: List<String>) {
val idSet = ids.toSet() val idSet = ids.toSet()
entitiesStateFlow.update { entities -> entitiesStateFlow.update { entities ->
entities.filterNot { idSet.contains(it.id) } entities.filterNot { it.id in idSet }
} }
} }
} }

@ -91,11 +91,10 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
} }
} }
fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> = fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> = when (version) {
when (version) { null -> this
null -> this else -> filter { it.changeListVersion > version }
else -> this.filter { it.changeListVersion > version } }
}
/** /**
* Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null * Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null
@ -105,7 +104,7 @@ private fun <T> List<T>.matchIds(
idGetter: (T) -> String, idGetter: (T) -> String,
) = when (ids) { ) = when (ids) {
null -> this null -> this
else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } } else -> ids.toSet().let { idSet -> filter { idGetter(it) in idSet } }
} }
/** /**

@ -28,20 +28,15 @@ import kotlinx.coroutines.flow.update
*/ */
class TestTopicDao : TopicDao { class TestTopicDao : TopicDao {
private var entitiesStateFlow = MutableStateFlow( private val entitiesStateFlow = MutableStateFlow(emptyList<TopicEntity>())
emptyList<TopicEntity>(),
)
override fun getTopicEntity(topicId: String): Flow<TopicEntity> { override fun getTopicEntity(topicId: String): Flow<TopicEntity> =
throw NotImplementedError("Unused in tests") throw NotImplementedError("Unused in tests")
}
override fun getTopicEntities(): Flow<List<TopicEntity>> = override fun getTopicEntities(): Flow<List<TopicEntity>> = entitiesStateFlow
entitiesStateFlow
override fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> = override fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> =
getTopicEntities() getTopicEntities().map { topics -> topics.filter { it.id in ids } }
.map { topics -> topics.filter { it.id in ids } }
override suspend fun getOneOffTopicEntities(): List<TopicEntity> = emptyList() override suspend fun getOneOffTopicEntities(): List<TopicEntity> = emptyList()
@ -55,15 +50,11 @@ class TestTopicDao : TopicDao {
override suspend fun upsertTopics(entities: List<TopicEntity>) { override suspend fun upsertTopics(entities: List<TopicEntity>) {
// Overwrite old values with new values // Overwrite old values with new values
entitiesStateFlow.update { oldValues -> entitiesStateFlow.update { oldValues -> (entities + oldValues).distinctBy(TopicEntity::id) }
(entities + oldValues).distinctBy(TopicEntity::id)
}
} }
override suspend fun deleteTopics(ids: List<String>) { override suspend fun deleteTopics(ids: List<String>) {
val idSet = ids.toSet() val idSet = ids.toSet()
entitiesStateFlow.update { entities -> entitiesStateFlow.update { entities -> entities.filterNot { it.id in idSet } }
entities.filterNot { idSet.contains(it.id) }
}
} }
} }

@ -52,7 +52,6 @@ internal object ListToMapMigration : DataMigration<UserPreferences> {
hasDoneListToMapMigration = true hasDoneListToMapMigration = true
} }
override suspend fun shouldMigrate(currentData: UserPreferences): Boolean { override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =
return !currentData.hasDoneListToMapMigration !currentData.hasDoneListToMapMigration
}
} }

@ -103,9 +103,7 @@ class NiaPreferencesDataSource @Inject constructor(
suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
userPreferences.updateData { userPreferences.updateData {
it.copy { it.copy { this.useDynamicColor = useDynamicColor }
this.useDynamicColor = useDynamicColor
}
} }
} }
@ -190,9 +188,7 @@ class NiaPreferencesDataSource @Inject constructor(
suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
userPreferences.updateData { userPreferences.updateData {
it.copy { it.copy { this.shouldHideOnboarding = shouldHideOnboarding }
this.shouldHideOnboarding = shouldHideOnboarding
}
} }
} }
} }

@ -28,6 +28,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color.Companion.Unspecified
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -79,7 +80,7 @@ fun DynamicAsyncImage(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
painter = if (isError.not() && !isLocalInspection) imageLoader else placeholder, painter = if (isError.not() && !isLocalInspection) imageLoader else placeholder,
contentDescription = contentDescription, contentDescription = contentDescription,
colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null, colorFilter = if (iconTint != Unspecified) ColorFilter.tint(iconTint) else null,
) )
} }
} }

@ -40,9 +40,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
fun NiaTopAppBar( fun NiaTopAppBar(
@StringRes titleRes: Int, @StringRes titleRes: Int,
navigationIcon: ImageVector, navigationIcon: ImageVector,
navigationIconContentDescription: String?, navigationIconContentDescription: String,
actionIcon: ImageVector, actionIcon: ImageVector,
actionIconContentDescription: String?, actionIconContentDescription: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
onNavigationClick: () -> Unit = {}, onNavigationClick: () -> Unit = {},

@ -230,10 +230,5 @@ fun LazyStaggeredGridState.scrollbarState(
return state return state
} }
private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float { private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float =
var sum = 0f fold(initial = 0f) { accumulator, listItem -> accumulator + selector(listItem) }
for (element in this) {
sum += selector(element)
}
return sum
}

@ -25,7 +25,7 @@ import androidx.compose.ui.graphics.Color
*/ */
@Immutable @Immutable
data class TintTheme( data class TintTheme(
val iconTint: Color? = null, val iconTint: Color = Color.Unspecified,
) )
/** /**

@ -32,12 +32,12 @@
android:fillColor="#8C4190" android:fillColor="#8C4190"
android:fillAlpha="0.11"/> android:fillAlpha="0.11"/>
<path <path
android:pathData="M171,119L326,119A25,25 0,0 1,351 144L351,144A25,25 0,0 1,326 169L171,169A25,25 0,0 1,146 144L146,144A25,25 0,0 1,171 119z" android:pathData="M171,119h155a25,25 0,0 1,25 25,25 25,0 0,1 -25,25H171a25,25 0,0 1,-25 -25,25 25,0 0,1 25,-25z"
android:strokeWidth="2" android:strokeWidth="2"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeColor="#5DD4FB"/> android:strokeColor="#5DD4FB"/>
<path <path
android:pathData="M156.023,33.705C154.247,37.971 153.333,42.543 153.333,47.161L188.666,47.161L224,47.161C224,42.543 223.086,37.971 221.31,33.705C219.535,29.44 216.932,25.563 213.651,22.298C210.37,19.033 206.475,16.444 202.188,14.677C197.901,12.91 193.307,12 188.667,12C184.026,12 179.432,12.91 175.145,14.677C170.858,16.444 166.963,19.033 163.682,22.298C160.401,25.563 157.798,29.44 156.023,33.705ZM153.333,47.161C153.333,51.778 152.409,56.35 150.615,60.616C148.821,64.882 146.192,68.758 142.877,72.023C139.562,75.288 135.627,77.878 131.296,79.645C126.965,81.412 122.323,82.322 117.635,82.322C112.947,82.322 108.305,81.412 103.974,79.645C99.643,77.878 95.708,75.288 92.393,72.023C89.078,68.758 86.449,64.882 84.655,60.616C82.861,56.35 81.938,51.778 81.938,47.161L117.635,47.161L153.333,47.161ZM12,47.161C12,42.543 12.904,37.971 14.661,33.705C16.419,29.439 18.994,25.563 22.242,22.298C25.489,19.033 29.344,16.443 33.586,14.676C37.829,12.909 42.376,12 46.968,12C51.561,12 56.108,12.909 60.351,14.676C64.593,16.443 68.448,19.033 71.695,22.298C74.942,25.563 77.518,29.439 79.276,33.705C81.033,37.971 81.938,42.543 81.938,47.161L46.968,47.161L12,47.161Z" android:pathData="M156.02,33.7a35,35 0,0 0,-2.69 13.46L224,47.16a35,35 0,0 0,-10.35 -24.86A35.34,35.34 0,0 0,188.67 12a35.48,35.48 0,0 0,-24.99 10.3,35.15 35.15,0 0,0 -7.66,11.4ZM153.33,47.16c0,4.62 -0.92,9.19 -2.72,13.46a35.13,35.13 0,0 1,-7.73 11.4,35.74 35.74,0 0,1 -11.58,7.63 36.17,36.17 0,0 1,-38.9 -7.63,35.13 35.13,0 0,1 -7.75,-11.4 34.7,34.7 0,0 1,-2.71 -13.46h71.4ZM12,47.16A35.33,35.33 0,0 1,22.24 22.3,34.96 34.96,0 0,1 46.97,12a34.8,34.8 0,0 1,24.72 10.3,35.19 35.19,0 0,1 10.25,24.86L12,47.16Z"
android:fillType="evenOdd"> android:fillType="evenOdd">
<aapt:attr name="android:fillColor"> <aapt:attr name="android:fillColor">
<gradient <gradient
@ -53,11 +53,11 @@
</path> </path>
<path <path
android:strokeWidth="1" android:strokeWidth="1"
android:pathData="M317.068,82.136L317.065,47.103L309.777,81.37L317.057,47.102L302.805,79.105L317.051,47.099L296.456,75.439L317.045,47.094L291.007,70.534L317.04,47.089L286.698,64.602L317.036,47.083L283.716,57.905L317.034,47.076L282.192,50.734L317.033,47.068L282.192,43.403L317.034,47.061L283.716,36.232L317.036,47.054L286.698,29.534L317.04,47.048L291.007,23.603L317.045,47.042L296.456,18.697L317.051,47.038L302.805,15.032L317.057,47.035L309.777,12.766L317.065,47.033L317.068,12L317.072,47.033L324.359,12.766L317.079,47.035L331.332,15.032L317.086,47.038L337.681,18.697L317.092,47.042L343.129,23.603L317.097,47.048L347.438,29.534L317.1,47.054L350.42,36.232L317.102,47.061L351.944,43.403L317.103,47.068L351.944,50.734L317.102,47.076L350.42,57.905L317.1,47.083L347.438,64.602L317.097,47.089L343.129,70.534L317.092,47.094L337.681,75.439L317.086,47.099L331.332,79.105L317.079,47.102L324.359,81.37L317.072,47.103L317.068,82.136Z" android:pathData="M317.07,82.14V47.1l-7.3,34.27 7.29,-34.27 -14.25,32 14.24,-32 -20.6,28.34 20.6,-28.35L291,70.53l26.03,-23.44L286.7,64.6l30.34,-17.52 -33.32,10.83 33.31,-10.83 -34.84,3.65 34.84,-3.66 -34.84,-3.67 34.84,3.66 -33.31,-10.83 33.32,10.82 -30.34,-17.52 30.34,17.52 -26.03,-23.45 26.04,23.44 -20.6,-28.34 20.6,28.34 -14.25,-32 14.26,32 -7.28,-34.27 7.29,34.26V12v35.03l7.29,-34.26 -7.28,34.27 14.25,-32 -14.24,32 20.6,-28.34 -20.6,28.34 26.04,-23.44 -26.03,23.45 30.34,-17.52 -30.34,17.52 33.32,-10.82 -33.32,10.83 34.84,-3.66 -34.84,3.67 34.84,3.66 -34.84,-3.65 33.32,10.83 -33.32,-10.83 30.34,17.52L317.1,47.1l26.03,23.44 -26.04,-23.44 20.6,28.35 -20.6,-28.34 14.24,32 -14.25,-32 7.28,34.27 -7.29,-34.27v35.04Z"
android:strokeLineJoin="round" android:strokeLineJoin="round"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeColor="#FF8B5E"/> android:strokeColor="#FF8B5E"/>
<path <path
android:pathData="M38.205,170L38.975,170A26.205,26.205 89.056,0 0,65.18 143.795L65.18,143.795A26.205,26.205 89.056,0 0,38.975 117.59L38.205,117.59A26.205,26.205 89.056,0 0,12 143.795L12,143.795A26.205,26.205 89.056,0 0,38.205 170z" android:pathData="M38.2,170h0.77a26.2,26.2 89.06,0 0,26.21 -26.2,26.2 26.2,89.06 0,0 -26.2,-26.21h-0.77A26.2,26.2 89.06,0 0,12 143.79,26.2 26.2,89.06 0,0 38.2,170z"
android:fillColor="#FFA8FF"/> android:fillColor="#FFA8FF"/>
</vector> </vector>

@ -16,7 +16,8 @@
package com.google.samples.apps.nowinandroid.core.designsystem package com.google.samples.apps.nowinandroid.core.designsystem
import android.os.Build import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
@ -222,60 +223,41 @@ class ThemeTest {
} }
@Composable @Composable
private fun dynamicLightColorSchemeWithFallback(): ColorScheme { private fun dynamicLightColorSchemeWithFallback(): ColorScheme = when {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { SDK_INT >= VERSION_CODES.S -> dynamicLightColorScheme(LocalContext.current)
dynamicLightColorScheme(LocalContext.current) else -> LightDefaultColorScheme
} else {
LightDefaultColorScheme
}
} }
@Composable @Composable
private fun dynamicDarkColorSchemeWithFallback(): ColorScheme { private fun dynamicDarkColorSchemeWithFallback(): ColorScheme = when {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { SDK_INT >= VERSION_CODES.S -> dynamicDarkColorScheme(LocalContext.current)
dynamicDarkColorScheme(LocalContext.current) else -> DarkDefaultColorScheme
} else {
DarkDefaultColorScheme
}
} }
private fun emptyGradientColors(colorScheme: ColorScheme): GradientColors { private fun emptyGradientColors(colorScheme: ColorScheme): GradientColors =
return GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp)) GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp))
}
private fun defaultGradientColors(colorScheme: ColorScheme): GradientColors { private fun defaultGradientColors(colorScheme: ColorScheme): GradientColors = GradientColors(
return GradientColors( top = colorScheme.inverseOnSurface,
top = colorScheme.inverseOnSurface, bottom = colorScheme.primaryContainer,
bottom = colorScheme.primaryContainer, container = colorScheme.surface,
container = colorScheme.surface, )
)
}
private fun dynamicGradientColorsWithFallback(colorScheme: ColorScheme): GradientColors { private fun dynamicGradientColorsWithFallback(colorScheme: ColorScheme): GradientColors = when {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { SDK_INT >= VERSION_CODES.S -> emptyGradientColors(colorScheme)
emptyGradientColors(colorScheme) else -> defaultGradientColors(colorScheme)
} else {
defaultGradientColors(colorScheme)
}
} }
private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme { private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme = BackgroundTheme(
return BackgroundTheme( color = colorScheme.surface,
color = colorScheme.surface, tonalElevation = 2.dp,
tonalElevation = 2.dp, )
)
}
private fun defaultTintTheme(): TintTheme { private fun defaultTintTheme(): TintTheme = TintTheme()
return TintTheme()
}
private fun dynamicTintThemeWithFallback(colorScheme: ColorScheme): TintTheme { private fun dynamicTintThemeWithFallback(colorScheme: ColorScheme): TintTheme = when {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { SDK_INT >= VERSION_CODES.S -> TintTheme(colorScheme.primary)
TintTheme(colorScheme.primary) else -> TintTheme()
} else {
TintTheme()
}
} }
/** /**

@ -37,22 +37,20 @@ class GetFollowableTopicsUseCase @Inject constructor(
* *
* @param sortBy - the field used to sort the topics. Default NONE = no sorting. * @param sortBy - the field used to sort the topics. Default NONE = no sorting.
*/ */
operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> { operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> = combine(
return combine( userDataRepository.userData,
userDataRepository.userData, topicsRepository.getTopics(),
topicsRepository.getTopics(), ) { userData, topics ->
) { userData, topics -> val followedTopics = topics
val followedTopics = topics .map { topic ->
.map { topic -> FollowableTopic(
FollowableTopic( topic = topic,
topic = topic, isFollowed = topic.id in userData.followedTopics,
isFollowed = topic.id in userData.followedTopics, )
)
}
when (sortBy) {
NAME -> followedTopics.sortedBy { it.topic.name }
else -> followedTopics
} }
when (sortBy) {
NAME -> followedTopics.sortedBy { it.topic.name }
else -> followedTopics
} }
} }
} }

@ -45,14 +45,13 @@ data class UserNewsResource internal constructor(
followableTopics = newsResource.topics.map { topic -> followableTopics = newsResource.topics.map { topic ->
FollowableTopic( FollowableTopic(
topic = topic, topic = topic,
isFollowed = userData.followedTopics.contains(topic.id), isFollowed = topic.id in userData.followedTopics,
) )
}, },
isSaved = userData.bookmarkedNewsResources.contains(newsResource.id), isSaved = newsResource.id in userData.bookmarkedNewsResources,
hasBeenViewed = userData.viewedNewsResources.contains(newsResource.id), hasBeenViewed = newsResource.id in userData.viewedNewsResources,
) )
} }
fun List<NewsResource>.mapToUserNewsResources(userData: UserData): List<UserNewsResource> { fun List<NewsResource>.mapToUserNewsResources(userData: UserData): List<UserNewsResource> =
return map { UserNewsResource(it, userData) } map { UserNewsResource(it, userData) }
}

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.network.di package com.google.samples.apps.nowinandroid.core.network.di
import android.content.Context import android.content.Context
import androidx.tracing.trace
import coil.ImageLoader import coil.ImageLoader
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import coil.util.DebugLogger import coil.util.DebugLogger
@ -51,16 +52,18 @@ internal object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun okHttpCallFactory(): Call.Factory = OkHttpClient.Builder() fun okHttpCallFactory(): Call.Factory = trace("NiaOkHttpClient") {
.addInterceptor( OkHttpClient.Builder()
HttpLoggingInterceptor() .addInterceptor(
.apply { HttpLoggingInterceptor()
if (BuildConfig.DEBUG) { .apply {
setLevel(HttpLoggingInterceptor.Level.BODY) if (BuildConfig.DEBUG) {
} setLevel(HttpLoggingInterceptor.Level.BODY)
}, }
) },
.build() )
.build()
}
/** /**
* Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this * Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this
@ -72,20 +75,21 @@ internal object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun imageLoader( fun imageLoader(
okHttpCallFactory: Call.Factory, // We specifically request dagger.Lazy here, so that it's not instantiated from Dagger.
okHttpCallFactory: dagger.Lazy<Call.Factory>,
@ApplicationContext application: Context, @ApplicationContext application: Context,
): ImageLoader = ImageLoader.Builder(application) ): ImageLoader = trace("NiaImageLoader") {
.callFactory(okHttpCallFactory) ImageLoader.Builder(application)
.components { .callFactory { okHttpCallFactory.get() }
add(SvgDecoder.Factory()) .components { add(SvgDecoder.Factory()) }
} // Assume most content images are versioned urls
// Assume most content images are versioned urls // but some problematic images are fetching each time
// but some problematic images are fetching each time .respectCacheHeaders(false)
.respectCacheHeaders(false) .apply {
.apply { if (BuildConfig.DEBUG) {
if (BuildConfig.DEBUG) { logger(DebugLogger())
logger(DebugLogger()) }
} }
} .build()
.build() }
} }

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.network.retrofit package com.google.samples.apps.nowinandroid.core.network.retrofit
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.network.BuildConfig import com.google.samples.apps.nowinandroid.core.network.BuildConfig
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
@ -73,17 +74,21 @@ private data class NetworkResponse<T>(
@Singleton @Singleton
internal class RetrofitNiaNetwork @Inject constructor( internal class RetrofitNiaNetwork @Inject constructor(
networkJson: Json, networkJson: Json,
okhttpCallFactory: Call.Factory, okhttpCallFactory: dagger.Lazy<Call.Factory>,
) : NiaNetworkDataSource { ) : NiaNetworkDataSource {
private val networkApi = Retrofit.Builder() private val networkApi = trace("RetrofitNiaNetwork") {
.baseUrl(NIA_BASE_URL) Retrofit.Builder()
.callFactory(okhttpCallFactory) .baseUrl(NIA_BASE_URL)
.addConverterFactory( // We use callFactory lambda here with dagger.Lazy<Call.Factory>
networkJson.asConverterFactory("application/json".toMediaType()), // to prevent initializing OkHttp on the main thread.
) .callFactory { okhttpCallFactory.get().newCall(it) }
.build() .addConverterFactory(
.create(RetrofitNiaNetworkApi::class.java) networkJson.asConverterFactory("application/json".toMediaType()),
)
.build()
.create(RetrofitNiaNetworkApi::class.java)
}
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> = override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
networkApi.getTopics(ids = ids).data networkApi.getTopics(ids = ids).data

@ -24,10 +24,10 @@ import android.app.PendingIntent
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build.VERSION import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat.checkSelfPermission
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.InboxStyle import androidx.core.app.NotificationCompat.InboxStyle
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@ -57,30 +57,24 @@ internal class SystemTrayNotifier @Inject constructor(
override fun postNewsNotifications( override fun postNewsNotifications(
newsResources: List<NewsResource>, newsResources: List<NewsResource>,
) = with(context) { ) = with(context) {
if (ActivityCompat.checkSelfPermission( if (checkSelfPermission(this, permission.POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
this,
permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return return
} }
val truncatedNewsResources = newsResources val truncatedNewsResources = newsResources.take(MAX_NUM_NOTIFICATIONS)
.take(MAX_NUM_NOTIFICATIONS)
val newsNotifications = truncatedNewsResources val newsNotifications = truncatedNewsResources.map { newsResource ->
.map { newsResource -> createNewsNotification {
createNewsNotification { setSmallIcon(
setSmallIcon( com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification,
com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification, )
) .setContentTitle(newsResource.title)
.setContentTitle(newsResource.title) .setContentText(newsResource.content)
.setContentText(newsResource.content) .setContentIntent(newsPendingIntent(newsResource))
.setContentIntent(newsPendingIntent(newsResource)) .setGroup(NEWS_NOTIFICATION_GROUP)
.setGroup(NEWS_NOTIFICATION_GROUP) .setAutoCancel(true)
.setAutoCancel(true)
}
} }
}
val summaryNotification = createNewsNotification { val summaryNotification = createNewsNotification {
val title = getString( val title = getString(
R.string.core_notifications_news_notification_group_summary, R.string.core_notifications_news_notification_group_summary,
@ -117,9 +111,7 @@ internal class SystemTrayNotifier @Inject constructor(
newsResources: List<NewsResource>, newsResources: List<NewsResource>,
title: String, title: String,
): InboxStyle = newsResources ): InboxStyle = newsResources
.fold(InboxStyle()) { inboxStyle, newsResource -> .fold(InboxStyle()) { inboxStyle, newsResource -> inboxStyle.addLine(newsResource.title) }
inboxStyle.addLine(newsResource.title)
}
.setBigContentTitle(title) .setBigContentTitle(title)
.setSummaryText(title) .setSummaryText(title)
} }

@ -25,7 +25,6 @@ import dagger.hilt.android.testing.HiltTestApplication
* A custom runner to set up the instrumented application class for tests. * A custom runner to set up the instrumented application class for tests.
*/ */
class NiaTestRunner : AndroidJUnitRunner() { class NiaTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
return super.newApplication(cl, HiltTestApplication::class.java.name, context) super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
} }

@ -43,9 +43,7 @@ class TestNewsRepository : NewsRepository {
} }
} }
query.filterNewsIds?.let { filterNewsIds -> query.filterNewsIds?.let { filterNewsIds ->
result = newsResources.filter { result = newsResources.filter { it.id in filterNewsIds }
filterNewsIds.contains(it.id)
}
} }
result result
} }

@ -32,7 +32,5 @@ class TestRecentSearchRepository : RecentSearchRepository {
cachedRecentSearches.add(RecentSearchQuery(searchQuery)) cachedRecentSearches.add(RecentSearchQuery(searchQuery))
} }
override suspend fun clearRecentSearches() { override suspend fun clearRecentSearches() = cachedRecentSearches.clear()
cachedRecentSearches.clear()
}
} }

@ -21,45 +21,36 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import org.jetbrains.annotations.TestOnly
class TestSearchContentsRepository : SearchContentsRepository { class TestSearchContentsRepository : SearchContentsRepository {
private val cachedTopics: MutableList<Topic> = mutableListOf() private val cachedTopics = MutableStateFlow(emptyList<Topic>())
private val cachedNewsResources: MutableList<NewsResource> = mutableListOf() private val cachedNewsResources = MutableStateFlow(emptyList<NewsResource>())
override suspend fun populateFtsData() { /* no-op */ } override suspend fun populateFtsData() = Unit
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf( override fun searchContents(searchQuery: String): Flow<SearchResult> =
SearchResult( combine(cachedTopics, cachedNewsResources) { topics, news ->
topics = cachedTopics.filter { SearchResult(
it.name.contains(searchQuery) || topics = topics.filter {
it.shortDescription.contains(searchQuery) || searchQuery in it.name || searchQuery in it.shortDescription || searchQuery in it.longDescription
it.longDescription.contains(searchQuery) },
}, newsResources = news.filter {
newsResources = cachedNewsResources.filter { searchQuery in it.content || searchQuery in it.title
it.content.contains(searchQuery) || },
it.title.contains(searchQuery) )
}, }
),
)
override fun getSearchContentsCount(): Flow<Int> = flow { override fun getSearchContentsCount(): Flow<Int> = combine(cachedTopics, cachedNewsResources) { topics, news -> topics.size + news.size }
emit(cachedTopics.size + cachedNewsResources.size)
}
/** @TestOnly
* Test only method to add the topics to the stored list in memory fun addTopics(topics: List<Topic>) = cachedTopics.update { it + topics }
*/
fun addTopics(topics: List<Topic>) {
cachedTopics.addAll(topics)
}
/** @TestOnly
* Test only method to add the news resources to the stored list in memory fun addNewsResources(newsResources: List<NewsResource>) =
*/ cachedNewsResources.update { it + newsResources }
fun addNewsResources(newsResources: List<NewsResource>) {
cachedNewsResources.addAll(newsResources)
}
} }

@ -33,9 +33,8 @@ class TestTopicsRepository : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> = topicsFlow override fun getTopics(): Flow<List<Topic>> = topicsFlow
override fun getTopic(id: String): Flow<Topic> { override fun getTopic(id: String): Flow<Topic> =
return topicsFlow.map { topics -> topics.find { it.id == id }!! } topicsFlow.map { topics -> topics.find { it.id == id }!! }
}
/** /**
* A test-only API to allow controlling the list of topics from tests. * A test-only API to allow controlling the list of topics from tests.

@ -32,11 +32,7 @@ import org.junit.runner.Description
class MainDispatcherRule( class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() { ) : TestWatcher() {
override fun starting(description: Description) { override fun starting(description: Description) = Dispatchers.setMain(testDispatcher)
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) { override fun finished(description: Description) = Dispatchers.resetMain()
Dispatchers.resetMain()
}
} }

@ -26,5 +26,5 @@ class TestAnalyticsHelper : AnalyticsHelper {
events.add(event) events.add(event)
} }
fun hasLogged(event: AnalyticsEvent) = events.contains(event) fun hasLogged(event: AnalyticsEvent) = event in events
} }

@ -26,9 +26,7 @@ class TestSyncManager : SyncManager {
override val isSyncing: Flow<Boolean> = syncStatusFlow override val isSyncing: Flow<Boolean> = syncStatusFlow
override fun requestSync() { override fun requestSync(): Unit = TODO("Not yet implemented")
TODO("Not yet implemented")
}
/** /**
* A test-only API to set the sync status from tests. * A test-only API to set the sync status from tests.

@ -50,7 +50,7 @@ fun rememberMetricsStateHolder(): Holder {
*/ */
@Composable @Composable
fun TrackJank( fun TrackJank(
vararg keys: Any?, vararg keys: Any,
reportMetric: suspend CoroutineScope.(state: Holder) -> Unit, reportMetric: suspend CoroutineScope.(state: Holder) -> Unit,
) { ) {
val metrics = rememberMetricsStateHolder() val metrics = rememberMetricsStateHolder()
@ -65,7 +65,7 @@ fun TrackJank(
*/ */
@Composable @Composable
fun TrackDisposableJank( fun TrackDisposableJank(
vararg keys: Any?, vararg keys: Any,
reportMetric: DisposableEffectScope.(state: Holder) -> DisposableEffectResult, reportMetric: DisposableEffectScope.(state: Holder) -> DisposableEffectResult,
) { ) {
val metrics = rememberMetricsStateHolder() val metrics = rememberMetricsStateHolder()

@ -17,6 +17,8 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.filter import androidx.compose.ui.test.filter
@ -30,8 +32,11 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.testing.TestLifecycleOwner
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -166,4 +171,29 @@ class BookmarksScreenTest {
) )
.assertExists() .assertExists()
} }
@Test
fun feed_whenLifecycleStops_undoBookmarkedStateIsCleared() = runTest {
var undoStateCleared = false
val testLifecycleOwner = TestLifecycleOwner(initialState = Lifecycle.State.STARTED)
composeTestRule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides testLifecycleOwner) {
BookmarksScreen(
feedState = NewsFeedUiState.Success(emptyList()),
onShowSnackbar = { _, _ -> false },
removeFromBookmarks = {},
onTopicClick = {},
onNewsResourceViewed = {},
clearUndoState = {
undoStateCleared = true
},
)
}
}
assertEquals(false, undoStateCleared)
testLifecycleOwner.handleLifecycleEvent(event = Lifecycle.Event.ON_STOP)
assertEquals(true, undoStateCleared)
}
} }

@ -42,13 +42,12 @@ import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridS
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -59,7 +58,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar
@ -127,15 +126,8 @@ internal fun BookmarksScreen(
} }
} }
val lifecycleOwner = LocalLifecycleOwner.current LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
DisposableEffect(lifecycleOwner) { clearUndoState()
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
clearUndoState()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
} }
when (feedState) { when (feedState) {
@ -237,7 +229,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Image( Image(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
painter = painterResource(id = R.drawable.feature_bookmarks_img_empty_bookmarks), painter = painterResource(id = R.drawable.feature_bookmarks_img_empty_bookmarks),
colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null, colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null,
contentDescription = null, contentDescription = null,
) )

@ -24,9 +24,7 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
const val BOOKMARKS_ROUTE = "bookmarks_route" const val BOOKMARKS_ROUTE = "bookmarks_route"
fun NavController.navigateToBookmarks(navOptions: NavOptions? = null) { fun NavController.navigateToBookmarks(navOptions: NavOptions) = navigate(BOOKMARKS_ROUTE, navOptions)
this.navigate(BOOKMARKS_ROUTE, navOptions)
}
fun NavGraphBuilder.bookmarksScreen( fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,

@ -18,6 +18,7 @@ plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco) alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.roborazzi)
} }
android { android {
@ -32,6 +33,7 @@ dependencies {
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)
testDemoImplementation(libs.roborazzi)
androidTestImplementation(projects.core.testing) androidTestImplementation(projects.core.testing)
} }

@ -81,9 +81,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.trace
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tracing.trace
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus.Denied import com.google.accompanist.permissions.PermissionStatus.Denied
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState

@ -30,9 +30,7 @@ const val FOR_YOU_ROUTE = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}"
private const val DEEP_LINK_URI_PATTERN = private const val DEEP_LINK_URI_PATTERN =
"https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}" "https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}"
fun NavController.navigateToForYou(navOptions: NavOptions? = null) { fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(FOR_YOU_ROUTE, navOptions)
this.navigate(FOR_YOU_ROUTE, navOptions)
}
fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) { fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable( composable(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

@ -26,9 +26,7 @@ import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
private const val INTERESTS_GRAPH_ROUTE_PATTERN = "interests_graph" private const val INTERESTS_GRAPH_ROUTE_PATTERN = "interests_graph"
const val INTERESTS_ROUTE = "interests_route" const val INTERESTS_ROUTE = "interests_route"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) { fun NavController.navigateToInterestsGraph(navOptions: NavOptions) = navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions)
this.navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions)
}
fun NavGraphBuilder.interestsGraph( fun NavGraphBuilder.interestsGraph(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,

@ -264,9 +264,7 @@ fun EmptySearchResultBody(
) { offset -> ) { offset ->
tryAnotherSearchString.getStringAnnotations(start = offset, end = offset) tryAnotherSearchString.getStringAnnotations(start = offset, end = offset)
.firstOrNull() .firstOrNull()
?.let { ?.let { onInterestsClick() }
onInterestsClick()
}
} }
} }
} }
@ -520,9 +518,7 @@ private fun SearchTextField(
} }
}, },
onValueChange = { onValueChange = {
if (!it.contains("\n")) { if ("\n" !in it) onSearchQueryChanged(it)
onSearchQueryChanged(it)
}
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

@ -24,9 +24,7 @@ import com.google.samples.apps.nowinandroid.feature.search.SearchRoute
const val SEARCH_ROUTE = "search_route" const val SEARCH_ROUTE = "search_route"
fun NavController.navigateToSearch(navOptions: NavOptions? = null) { fun NavController.navigateToSearch(navOptions: NavOptions? = null) = navigate(SEARCH_ROUTE, navOptions)
this.navigate(SEARCH_ROUTE, navOptions)
}
fun NavGraphBuilder.searchScreen( fun NavGraphBuilder.searchScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,

@ -26,6 +26,7 @@ import com.google.samples.apps.nowinandroid.core.testing.data.topicsTestData
import com.google.samples.apps.nowinandroid.core.testing.repository.TestRecentSearchRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchContentsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.search.RecentSearchQueriesUiState.Success import com.google.samples.apps.nowinandroid.feature.search.RecentSearchQueriesUiState.Success
import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery
@ -71,6 +72,7 @@ class SearchViewModelTest {
recentSearchRepository = recentSearchRepository, recentSearchRepository = recentSearchRepository,
analyticsHelper = NoOpAnalyticsHelper(), analyticsHelper = NoOpAnalyticsHelper(),
) )
userDataRepository.setUserData(emptyUserData)
} }
@Test @Test
@ -100,8 +102,7 @@ class SearchViewModelTest {
searchContentsRepository.addTopics(topicsTestData) searchContentsRepository.addTopics(topicsTestData)
val result = viewModel.searchResultUiState.value val result = viewModel.searchResultUiState.value
// TODO: Figure out to get the latest emitted ui State? The result is emitted as EmptyQuery assertIs<SearchResultUiState.Success>(result)
// assertIs<Success>(result)
collectJob.cancel() collectJob.cancel()
} }

@ -117,22 +117,16 @@ private fun topicUiState(
when (followedTopicToTopicResult) { when (followedTopicToTopicResult) {
is Result.Success -> { is Result.Success -> {
val (followedTopics, topic) = followedTopicToTopicResult.data val (followedTopics, topic) = followedTopicToTopicResult.data
val followed = followedTopics.contains(topicId)
TopicUiState.Success( TopicUiState.Success(
followableTopic = FollowableTopic( followableTopic = FollowableTopic(
topic = topic, topic = topic,
isFollowed = followed, isFollowed = topicId in followedTopics,
), ),
) )
} }
is Result.Loading -> { is Result.Loading -> TopicUiState.Loading
TopicUiState.Loading is Result.Error -> TopicUiState.Error
}
is Result.Error -> {
TopicUiState.Error
}
} }
} }
} }
@ -151,26 +145,13 @@ private fun newsUiState(
val bookmark: Flow<Set<String>> = userDataRepository.userData val bookmark: Flow<Set<String>> = userDataRepository.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
return combine( return combine(newsStream, bookmark, ::Pair)
newsStream,
bookmark,
::Pair,
)
.asResult() .asResult()
.map { newsToBookmarksResult -> .map { newsToBookmarksResult ->
when (newsToBookmarksResult) { when (newsToBookmarksResult) {
is Result.Success -> { is Result.Success -> NewsUiState.Success(newsToBookmarksResult.data.first)
val news = newsToBookmarksResult.data.first is Result.Loading -> NewsUiState.Loading
NewsUiState.Success(news) is Result.Error -> NewsUiState.Error
}
is Result.Loading -> {
NewsUiState.Loading
}
is Result.Error -> {
NewsUiState.Error
}
} }
} }
} }

@ -40,7 +40,7 @@ internal class TopicArgs(val topicId: String) {
fun NavController.navigateToTopic(topicId: String) { fun NavController.navigateToTopic(topicId: String) {
val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING) val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
this.navigate("topic_route/$encodedId") { navigate("topic_route/$encodedId") {
launchSingleTop = true launchSingleTop = true
} }
} }

@ -15,7 +15,7 @@ androidxCoreSplashscreen = "1.0.1"
androidxDataStore = "1.0.0" androidxDataStore = "1.0.0"
androidxEspresso = "3.5.1" androidxEspresso = "3.5.1"
androidxHiltNavigationCompose = "1.0.0" androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.6.2" androidxLifecycle = "2.7.0"
androidxMacroBenchmark = "1.2.2" androidxMacroBenchmark = "1.2.2"
androidxMetrics = "1.0.0-alpha04" androidxMetrics = "1.0.0-alpha04"
androidxNavigation = "2.7.4" androidxNavigation = "2.7.4"
@ -24,7 +24,7 @@ androidxTestCore = "1.5.0"
androidxTestExt = "1.1.5" androidxTestExt = "1.1.5"
androidxTestRules = "1.5.0" androidxTestRules = "1.5.0"
androidxTestRunner = "1.5.2" androidxTestRunner = "1.5.2"
androidxTracing = "1.1.0" androidxTracing = "1.3.0-alpha02"
androidxUiAutomator = "2.2.0" androidxUiAutomator = "2.2.0"
androidxWindowManager = "1.2.0" androidxWindowManager = "1.2.0"
androidxWork = "2.9.0" androidxWork = "2.9.0"
@ -46,7 +46,7 @@ kotlinxDatetime = "0.5.0"
kotlinxSerializationJson = "1.6.0" kotlinxSerializationJson = "1.6.0"
ksp = "1.9.21-1.0.16" ksp = "1.9.21-1.0.16"
okhttp = "4.12.0" okhttp = "4.12.0"
protobuf = "3.24.4" protobuf = "3.25.2"
protobufPlugin = "0.9.4" protobufPlugin = "0.9.4"
retrofit = "2.9.0" retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "1.0.0" retrofitKotlinxSerializationJson = "1.0.0"
@ -82,6 +82,7 @@ androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscree
androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtimeTesting = { group = "androidx.lifecycle", name = "lifecycle-runtime-testing", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" } androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" }

@ -34,15 +34,13 @@ import org.jetbrains.uast.UQualifiedReferenceExpression
*/ */
class DesignSystemDetector : Detector(), Detector.UastScanner { class DesignSystemDetector : Detector(), Detector.UastScanner {
override fun getApplicableUastTypes(): List<Class<out UElement>> { override fun getApplicableUastTypes(): List<Class<out UElement>> = listOf(
return listOf( UCallExpression::class.java,
UCallExpression::class.java, UQualifiedReferenceExpression::class.java,
UQualifiedReferenceExpression::class.java, )
)
}
override fun createUastHandler(context: JavaContext): UElementHandler { override fun createUastHandler(context: JavaContext): UElementHandler =
return object : UElementHandler() { object : UElementHandler() {
override fun visitCallExpression(node: UCallExpression) { override fun visitCallExpression(node: UCallExpression) {
val name = node.methodName ?: return val name = node.methodName ?: return
val preferredName = METHOD_NAMES[name] ?: return val preferredName = METHOD_NAMES[name] ?: return
@ -55,7 +53,6 @@ class DesignSystemDetector : Detector(), Detector.UastScanner {
reportIssue(context, node, name, preferredName) reportIssue(context, node, name, preferredName)
} }
} }
}
companion object { companion object {
@JvmField @JvmField

Loading…
Cancel
Save