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
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)
id: screenshotsverify
continue-on-error: true
run: ./gradlew verifyRoborazziDemoDebug
- name: Prevent pushing new screenshots if this is a fork
id: checkfork
id: checkfork_screenshots
continue-on-error: false
if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: |
@ -82,13 +109,9 @@ jobs:
- name: Build all build type and flavor permutations
run: ./gradlew :app:assemble :benchmarks:assemble
-x pixel6Api33ProdNonMinifiedReleaseAndroidTest
-x pixel6Api33ProdNonMinifiedBenchmarkAndroidTest
-x pixel6Api33DemoNonMinifiedReleaseAndroidTest
-x pixel6Api33DemoNonMinifiedBenchmarkAndroidTest
-x collectDemoNonMinifiedReleaseBaselineProfile
-x collectDemoNonMinifiedBenchmarkBaselineProfile
-x collectProdNonMinifiedReleaseBaselineProfile
-x collectProdNonMinifiedBenchmarkBaselineProfile
- name: Upload build outputs (APKs)
uses: actions/upload-artifact@v4
@ -117,13 +140,31 @@ jobs:
run: ./gradlew :app:checkProdReleaseBadging
androidTest:
runs-on: macOS-latest # enables hardware acceleration in the virtual machine
runs-on: ubuntu-latest
timeout-minutes: 55
strategy:
matrix:
api-level: [26, 30]
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
uses: actions/checkout@v4

@ -7,10 +7,17 @@ on:
jobs:
build:
runs-on: macos-latest
runs-on: ubuntu-latest
timeout-minutes: 120
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
uses: actions/checkout@v4

@ -70,7 +70,8 @@ androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.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:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1

@ -57,17 +57,6 @@ android {
// Ensure Baseline Profile is fresh for release builds.
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 {

@ -71,19 +71,20 @@ androidx.hilt:hilt-navigation:1.0.0
androidx.hilt:hilt-work:1.1.0
androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.6.2
androidx.lifecycle:lifecycle-common:2.6.2
androidx.lifecycle:lifecycle-livedata-core:2.6.2
androidx.lifecycle:lifecycle-livedata:2.6.2
androidx.lifecycle:lifecycle-process:2.6.2
androidx.lifecycle:lifecycle-runtime-compose:2.6.2
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2
androidx.lifecycle:lifecycle-runtime:2.6.2
androidx.lifecycle:lifecycle-service:2.6.2
androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2
androidx.lifecycle:lifecycle-viewmodel:2.6.2
androidx.lifecycle:lifecycle-common-java8:2.7.0
androidx.lifecycle:lifecycle-common:2.7.0
androidx.lifecycle:lifecycle-livedata-core-ktx:2.7.0
androidx.lifecycle:lifecycle-livedata-core:2.7.0
androidx.lifecycle:lifecycle-livedata:2.7.0
androidx.lifecycle:lifecycle-process:2.7.0
androidx.lifecycle:lifecycle-runtime-compose:2.7.0
androidx.lifecycle:lifecycle-runtime-ktx:2.7.0
androidx.lifecycle:lifecycle-runtime:2.7.0
androidx.lifecycle:lifecycle-service:2.7.0
androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0
androidx.lifecycle:lifecycle-viewmodel:2.7.0
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
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:2.4.0
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.1.0
androidx.tracing:tracing:1.1.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:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window:window:1.0.0
androidx.work:work-runtime-ktx:2.9.0-rc01
androidx.work:work-runtime:2.9.0-rc01
androidx.work:work-runtime-ktx:2.9.0
androidx.work:work-runtime:2.9.0
com.caverock:androidsvg-aar:1.4
com.google.accompanist:accompanist-drawablepainter: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:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:1.3
com.google.protobuf:protobuf-javalite:3.24.4
com.google.protobuf:protobuf-kotlin-lite:3.24.4
com.google.protobuf:protobuf-javalite:3.25.2
com.google.protobuf:protobuf-kotlin-lite:3.25.2
com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0
com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0

@ -90,7 +90,7 @@ class NavigationTest {
lateinit var topicsRepository: TopicsRepository
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
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
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
@ -37,7 +36,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.metrics.performance.JankStats
import androidx.profileinstaller.ProfileVerifier
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
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.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
private const val TAG = "MainActivity"
@ -90,9 +85,7 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.onEach {
uiState = it
}
.onEach { uiState = it }
.collect()
}
}
@ -152,48 +145,12 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
lazyStats.get().isTrackingEnabled = true
lifecycleScope.launch {
logCompilationStatus()
}
}
override fun onPause() {
super.onPause()
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.ImageLoaderFactory
import com.google.samples.apps.nowinandroid.sync.initializers.Sync
import com.google.samples.apps.nowinandroid.util.ProfileVerifierLogger
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import javax.inject.Provider
/**
* [Application] class for NiA
@ -30,12 +30,16 @@ import javax.inject.Provider
@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var imageLoader: Provider<ImageLoader>
lateinit var imageLoader: dagger.Lazy<ImageLoader>
@Inject
lateinit var profileVerifierLogger: ProfileVerifierLogger
override fun onCreate() {
super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this)
profileVerifierLogger()
}
override fun newImageLoader(): ImageLoader = imageLoader.get()

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

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

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

@ -20,10 +20,10 @@
android:viewportWidth="108"
android:viewportHeight="108">
<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"/>
<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:fillType="evenOdd"/>
</vector>

@ -24,10 +24,10 @@
android:pathData="M0,0h108v108h-108z"
android:fillColor="@color/ic_launcher_background_tint"/>
<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"/>
<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:fillType="evenOdd"/>

@ -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.UserNewsResourceRepository
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.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
@ -140,6 +141,7 @@ class NiaAppScreenSizesScreenshotTests {
) {
TestHarness(size = DpSize(width, height)) {
BoxWithConstraints {
NiaTheme {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
@ -151,6 +153,7 @@ class NiaAppScreenSizesScreenshotTests {
}
}
}
}
composeTestRule.onRoot()
.captureRoboImage(

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
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.NiaBuildType
import com.google.samples.apps.nowinandroid.configureFlavors
plugins {
@ -35,23 +34,6 @@ android {
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,
// 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.

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

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

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

@ -44,6 +44,9 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").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.configurePrintApksTask
import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@ -51,6 +52,8 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
}
dependencies {
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) {
DEBUG(".debug"),
RELEASE,
BENCHMARK(".benchmark")
}

@ -86,14 +86,12 @@ internal abstract class PrintApkLocationTask : DefaultTask() {
fun taskAction() {
val hasFiles = sources.orNull?.any { directory ->
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")
// Don't print APK location if there are no androidTest source files
if (!hasFiles) {
return
}
if (!hasFiles) return
val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get())
?: throw RuntimeException("Cannot load APKs")

@ -23,15 +23,10 @@ import kotlinx.coroutines.flow.onStart
sealed interface Result<out 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>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> {
return this
.map<T, Result<T>> {
Result.Success(it)
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { 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>> =
recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries ->
searchQueries.map {
it.asExternalModel()
}
searchQueries.map { it.asExternalModel() }
}
override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries()

@ -26,10 +26,10 @@ import javax.inject.Inject
* Fake implementation of the [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>> =
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 {
override suspend fun populateFtsData() { /* no-op */ }
override suspend fun populateFtsData() = Unit
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()
override fun getSearchContentsCount(): Flow<Int> = flowOf(1)
}

@ -55,9 +55,8 @@ class FakeTopicsRepository @Inject constructor(
)
}.flowOn(ioDispatcher)
override fun getTopic(id: String): Flow<Topic> {
return getTopics().map { it.first { topic -> topic.id == id } }
}
override fun getTopic(id: String): Flow<Topic> = getTopics()
.map { it.first { topic -> topic.id == id } }
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.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.filter { sampleTopic1 in it.topics }
.mapToUserNewsResources(emptyUserData),
userNewsResources.first(),
)
@ -104,7 +104,7 @@ class CompositeUserNewsResourceRepositoryTest {
// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.filter { sampleTopic1 in it.topics }
.mapToUserNewsResources(userData),
userNewsResources.first(),
)

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

@ -34,9 +34,7 @@ val nonPresentInterestsIds = setOf("2")
*/
class TestNewsResourceDao : NewsResourceDao {
private var entitiesStateFlow = MutableStateFlow(
emptyList<NewsResourceEntity>(),
)
private val entitiesStateFlow = MutableStateFlow(emptyList<NewsResourceEntity>())
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
@ -131,7 +129,7 @@ class TestNewsResourceDao : NewsResourceDao {
override suspend fun deleteNewsResources(ids: List<String>) {
val idSet = ids.toSet()
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> =
when (version) {
fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> = when (version) {
null -> this
else -> this.filter { it.changeListVersion > version }
}
else -> filter { it.changeListVersion > version }
}
/**
* 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,
) = when (ids) {
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 {
private var entitiesStateFlow = MutableStateFlow(
emptyList<TopicEntity>(),
)
private val entitiesStateFlow = MutableStateFlow(emptyList<TopicEntity>())
override fun getTopicEntity(topicId: String): Flow<TopicEntity> {
override fun getTopicEntity(topicId: String): Flow<TopicEntity> =
throw NotImplementedError("Unused in tests")
}
override fun getTopicEntities(): Flow<List<TopicEntity>> =
entitiesStateFlow
override fun getTopicEntities(): Flow<List<TopicEntity>> = entitiesStateFlow
override fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> =
getTopicEntities()
.map { topics -> topics.filter { it.id in ids } }
getTopicEntities().map { topics -> topics.filter { it.id in ids } }
override suspend fun getOneOffTopicEntities(): List<TopicEntity> = emptyList()
@ -55,15 +50,11 @@ class TestTopicDao : TopicDao {
override suspend fun upsertTopics(entities: List<TopicEntity>) {
// Overwrite old values with new values
entitiesStateFlow.update { oldValues ->
(entities + oldValues).distinctBy(TopicEntity::id)
}
entitiesStateFlow.update { oldValues -> (entities + oldValues).distinctBy(TopicEntity::id) }
}
override suspend fun deleteTopics(ids: List<String>) {
val idSet = ids.toSet()
entitiesStateFlow.update { entities ->
entities.filterNot { idSet.contains(it.id) }
}
entitiesStateFlow.update { entities -> entities.filterNot { it.id in idSet } }
}
}

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

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

@ -28,6 +28,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color.Companion.Unspecified
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
@ -79,7 +80,7 @@ fun DynamicAsyncImage(
contentScale = ContentScale.Crop,
painter = if (isError.not() && !isLocalInspection) imageLoader else placeholder,
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(
@StringRes titleRes: Int,
navigationIcon: ImageVector,
navigationIconContentDescription: String?,
navigationIconContentDescription: String,
actionIcon: ImageVector,
actionIconContentDescription: String?,
actionIconContentDescription: String,
modifier: Modifier = Modifier,
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
onNavigationClick: () -> Unit = {},

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

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

@ -32,12 +32,12 @@
android:fillColor="#8C4190"
android:fillAlpha="0.11"/>
<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:fillColor="#00000000"
android:strokeColor="#5DD4FB"/>
<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">
<aapt:attr name="android:fillColor">
<gradient
@ -53,11 +53,11 @@
</path>
<path
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:fillColor="#00000000"
android:strokeColor="#FF8B5E"/>
<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"/>
</vector>

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

@ -37,8 +37,7 @@ class GetFollowableTopicsUseCase @Inject constructor(
*
* @param sortBy - the field used to sort the topics. Default NONE = no sorting.
*/
operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> {
return combine(
operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> = combine(
userDataRepository.userData,
topicsRepository.getTopics(),
) { userData, topics ->
@ -54,7 +53,6 @@ class GetFollowableTopicsUseCase @Inject constructor(
else -> followedTopics
}
}
}
}
enum class TopicSortField {

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

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.network.di
import android.content.Context
import androidx.tracing.trace
import coil.ImageLoader
import coil.decode.SvgDecoder
import coil.util.DebugLogger
@ -51,7 +52,8 @@ internal object NetworkModule {
@Provides
@Singleton
fun okHttpCallFactory(): Call.Factory = OkHttpClient.Builder()
fun okHttpCallFactory(): Call.Factory = trace("NiaOkHttpClient") {
OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor()
.apply {
@ -61,6 +63,7 @@ internal object NetworkModule {
},
)
.build()
}
/**
* Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this
@ -72,13 +75,13 @@ internal object NetworkModule {
@Provides
@Singleton
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,
): ImageLoader = ImageLoader.Builder(application)
.callFactory(okHttpCallFactory)
.components {
add(SvgDecoder.Factory())
}
): ImageLoader = trace("NiaImageLoader") {
ImageLoader.Builder(application)
.callFactory { okHttpCallFactory.get() }
.components { add(SvgDecoder.Factory()) }
// Assume most content images are versioned urls
// but some problematic images are fetching each time
.respectCacheHeaders(false)
@ -88,4 +91,5 @@ internal object NetworkModule {
}
}
.build()
}
}

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

@ -24,10 +24,10 @@ import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
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_CODES
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityCompat.checkSelfPermission
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.InboxStyle
import androidx.core.app.NotificationManagerCompat
@ -57,19 +57,13 @@ internal class SystemTrayNotifier @Inject constructor(
override fun postNewsNotifications(
newsResources: List<NewsResource>,
) = with(context) {
if (ActivityCompat.checkSelfPermission(
this,
permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
if (checkSelfPermission(this, permission.POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
return
}
val truncatedNewsResources = newsResources
.take(MAX_NUM_NOTIFICATIONS)
val truncatedNewsResources = newsResources.take(MAX_NUM_NOTIFICATIONS)
val newsNotifications = truncatedNewsResources
.map { newsResource ->
val newsNotifications = truncatedNewsResources.map { newsResource ->
createNewsNotification {
setSmallIcon(
com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification,
@ -117,9 +111,7 @@ internal class SystemTrayNotifier @Inject constructor(
newsResources: List<NewsResource>,
title: String,
): InboxStyle = newsResources
.fold(InboxStyle()) { inboxStyle, newsResource ->
inboxStyle.addLine(newsResource.title)
}
.fold(InboxStyle()) { inboxStyle, newsResource -> inboxStyle.addLine(newsResource.title) }
.setBigContentTitle(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.
*/
class NiaTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

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

@ -32,7 +32,5 @@ class TestRecentSearchRepository : RecentSearchRepository {
cachedRecentSearches.add(RecentSearchQuery(searchQuery))
}
override suspend fun clearRecentSearches() {
cachedRecentSearches.clear()
}
override suspend fun clearRecentSearches() = 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.Topic
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import org.jetbrains.annotations.TestOnly
class TestSearchContentsRepository : SearchContentsRepository {
private val cachedTopics: MutableList<Topic> = mutableListOf()
private val cachedNewsResources: MutableList<NewsResource> = mutableListOf()
private val cachedTopics = MutableStateFlow(emptyList<Topic>())
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> =
combine(cachedTopics, cachedNewsResources) { topics, news ->
SearchResult(
topics = cachedTopics.filter {
it.name.contains(searchQuery) ||
it.shortDescription.contains(searchQuery) ||
it.longDescription.contains(searchQuery)
topics = topics.filter {
searchQuery in it.name || searchQuery in it.shortDescription || searchQuery in it.longDescription
},
newsResources = cachedNewsResources.filter {
it.content.contains(searchQuery) ||
it.title.contains(searchQuery)
newsResources = news.filter {
searchQuery in it.content || searchQuery in it.title
},
),
)
override fun getSearchContentsCount(): Flow<Int> = flow {
emit(cachedTopics.size + cachedNewsResources.size)
}
/**
* Test only method to add the topics to the stored list in memory
*/
fun addTopics(topics: List<Topic>) {
cachedTopics.addAll(topics)
}
override fun getSearchContentsCount(): Flow<Int> = combine(cachedTopics, cachedNewsResources) { topics, news -> topics.size + news.size }
/**
* Test only method to add the news resources to the stored list in memory
*/
fun addNewsResources(newsResources: List<NewsResource>) {
cachedNewsResources.addAll(newsResources)
}
@TestOnly
fun addTopics(topics: List<Topic>) = cachedTopics.update { it + topics }
@TestOnly
fun addNewsResources(newsResources: List<NewsResource>) =
cachedNewsResources.update { it + newsResources }
}

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

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

@ -26,5 +26,5 @@ class TestAnalyticsHelper : AnalyticsHelper {
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 fun requestSync() {
TODO("Not yet implemented")
}
override fun requestSync(): Unit = TODO("Not yet implemented")
/**
* A test-only API to set the sync status from tests.

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

@ -17,6 +17,8 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks
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.assertHasClickAction
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.performClick
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.ui.NewsFeedUiState
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
@ -166,4 +171,29 @@ class BookmarksScreenTest {
)
.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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar
@ -127,16 +126,9 @@ internal fun BookmarksScreen(
}
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
clearUndoState()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
when (feedState) {
Loading -> LoadingState(modifier)
@ -237,7 +229,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Image(
modifier = Modifier.fillMaxWidth(),
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,
)

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

@ -18,6 +18,7 @@ plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.roborazzi)
}
android {
@ -32,6 +33,7 @@ dependencies {
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)
testImplementation(projects.core.testing)
testDemoImplementation(libs.roborazzi)
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.max
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.trace
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tracing.trace
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus.Denied
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 =
"https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}"
fun NavController.navigateToForYou(navOptions: NavOptions? = null) {
this.navigate(FOR_YOU_ROUTE, navOptions)
}
fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(FOR_YOU_ROUTE, navOptions)
fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
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"
const val INTERESTS_ROUTE = "interests_route"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) {
this.navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions)
}
fun NavController.navigateToInterestsGraph(navOptions: NavOptions) = navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions)
fun NavGraphBuilder.interestsGraph(
onTopicClick: (String) -> Unit,

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

@ -24,9 +24,7 @@ import com.google.samples.apps.nowinandroid.feature.search.SearchRoute
const val SEARCH_ROUTE = "search_route"
fun NavController.navigateToSearch(navOptions: NavOptions? = null) {
this.navigate(SEARCH_ROUTE, navOptions)
}
fun NavController.navigateToSearch(navOptions: NavOptions? = null) = navigate(SEARCH_ROUTE, navOptions)
fun NavGraphBuilder.searchScreen(
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.TestSearchContentsRepository
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.feature.search.RecentSearchQueriesUiState.Success
import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery
@ -71,6 +72,7 @@ class SearchViewModelTest {
recentSearchRepository = recentSearchRepository,
analyticsHelper = NoOpAnalyticsHelper(),
)
userDataRepository.setUserData(emptyUserData)
}
@Test
@ -100,8 +102,7 @@ class SearchViewModelTest {
searchContentsRepository.addTopics(topicsTestData)
val result = viewModel.searchResultUiState.value
// TODO: Figure out to get the latest emitted ui State? The result is emitted as EmptyQuery
// assertIs<Success>(result)
assertIs<SearchResultUiState.Success>(result)
collectJob.cancel()
}

@ -117,22 +117,16 @@ private fun topicUiState(
when (followedTopicToTopicResult) {
is Result.Success -> {
val (followedTopics, topic) = followedTopicToTopicResult.data
val followed = followedTopics.contains(topicId)
TopicUiState.Success(
followableTopic = FollowableTopic(
topic = topic,
isFollowed = followed,
isFollowed = topicId in followedTopics,
),
)
}
is Result.Loading -> {
TopicUiState.Loading
}
is Result.Error -> {
TopicUiState.Error
}
is Result.Loading -> TopicUiState.Loading
is Result.Error -> TopicUiState.Error
}
}
}
@ -151,26 +145,13 @@ private fun newsUiState(
val bookmark: Flow<Set<String>> = userDataRepository.userData
.map { it.bookmarkedNewsResources }
return combine(
newsStream,
bookmark,
::Pair,
)
return combine(newsStream, bookmark, ::Pair)
.asResult()
.map { newsToBookmarksResult ->
when (newsToBookmarksResult) {
is Result.Success -> {
val news = newsToBookmarksResult.data.first
NewsUiState.Success(news)
}
is Result.Loading -> {
NewsUiState.Loading
}
is Result.Error -> {
NewsUiState.Error
}
is Result.Success -> NewsUiState.Success(newsToBookmarksResult.data.first)
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) {
val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
this.navigate("topic_route/$encodedId") {
navigate("topic_route/$encodedId") {
launchSingleTop = true
}
}

@ -15,7 +15,7 @@ androidxCoreSplashscreen = "1.0.1"
androidxDataStore = "1.0.0"
androidxEspresso = "3.5.1"
androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.6.2"
androidxLifecycle = "2.7.0"
androidxMacroBenchmark = "1.2.2"
androidxMetrics = "1.0.0-alpha04"
androidxNavigation = "2.7.4"
@ -24,7 +24,7 @@ androidxTestCore = "1.5.0"
androidxTestExt = "1.1.5"
androidxTestRules = "1.5.0"
androidxTestRunner = "1.5.2"
androidxTracing = "1.1.0"
androidxTracing = "1.3.0-alpha02"
androidxUiAutomator = "2.2.0"
androidxWindowManager = "1.2.0"
androidxWork = "2.9.0"
@ -46,7 +46,7 @@ kotlinxDatetime = "0.5.0"
kotlinxSerializationJson = "1.6.0"
ksp = "1.9.21-1.0.16"
okhttp = "4.12.0"
protobuf = "3.24.4"
protobuf = "3.25.2"
protobufPlugin = "0.9.4"
retrofit = "2.9.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-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-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-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" }
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 {
override fun getApplicableUastTypes(): List<Class<out UElement>> {
return listOf(
override fun getApplicableUastTypes(): List<Class<out UElement>> = listOf(
UCallExpression::class.java,
UQualifiedReferenceExpression::class.java,
)
}
override fun createUastHandler(context: JavaContext): UElementHandler {
return object : UElementHandler() {
override fun createUastHandler(context: JavaContext): UElementHandler =
object : UElementHandler() {
override fun visitCallExpression(node: UCallExpression) {
val name = node.methodName ?: return
val preferredName = METHOD_NAMES[name] ?: return
@ -55,7 +53,6 @@ class DesignSystemDetector : Detector(), Detector.UastScanner {
reportIssue(context, node, name, preferredName)
}
}
}
companion object {
@JvmField

Loading…
Cancel
Save