Merge pull request #16 from lihenggui/compose_multiplatform

Make :core:database and :core:common module as a multiplatform module
pull/1323/head
Mercury Li 2 years ago committed by GitHub
commit b00f1c9d72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -147,6 +147,17 @@ jobs:
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

@ -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

@ -18,7 +18,7 @@ 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 com.google.samples.apps.nowinandroid.core.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch

@ -44,8 +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())
}
}
}

@ -85,13 +85,12 @@ internal fun Project.configureKotlinMultiplatform() {
project.tasks.named("linuxX64Test") { enabled = HostManager.hostIsLinux }
project.tasks.named("linkDebugTestLinuxX64") { enabled = HostManager.hostIsLinux }
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf(
// Suppress warning:'expect'/'actual' classes (including interfaces, objects,
// annotations, enums, and 'actual' typealiases) are in Beta.
"-Xexpect-actual-classes",
)
// Suppress 'expect'/'actual' classes are in Beta.
targets.configureEach {
compilations.configureEach {
compilerOptions.configure {
freeCompilerArgs.addAll("-Xexpect-actual-classes")
}
}
}

@ -1,5 +1,5 @@
/*
* Copyright 2022 The Android Open Source Project
* Copyright 2024 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.
@ -14,16 +14,24 @@
* limitations under the License.
*/
plugins {
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.kmp.library)
alias(libs.plugins.nowinandroid.kotlin.inject)
alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.nowinandroid.android.hilt)
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.common"
}
dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
}
}
}

@ -0,0 +1,30 @@
/*
* Copyright 2024 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.core.di
import kotlinx.coroutines.Dispatchers
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
@Component
actual abstract class DispatchersComponent {
@Provides
actual fun providesIODispatcher(): IODispatcher = Dispatchers.IO
@Provides
actual fun providesDefaultDispatcher(): DefaultDispatcher = Dispatchers.Default
}

@ -0,0 +1,32 @@
/*
* Copyright 2024 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.core.di
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
typealias ApplicationScope = CoroutineScope
@Component
abstract class CoroutineScopeComponent {
@Provides
fun providesCoroutineScope(
dispatcher: DefaultDispatcher,
): ApplicationScope = CoroutineScope(SupervisorJob() + dispatcher)
}

@ -1,5 +1,5 @@
/*
* Copyright 2022 The Android Open Source Project
* Copyright 2024 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.
@ -14,16 +14,18 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.network
package com.google.samples.apps.nowinandroid.core.di
import javax.inject.Qualifier
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlinx.coroutines.CoroutineDispatcher
import me.tatarka.inject.annotations.Provides
@Qualifier
@Retention(RUNTIME)
annotation class Dispatcher(val niaDispatcher: NiaDispatchers)
typealias DefaultDispatcher = CoroutineDispatcher
typealias IODispatcher = CoroutineDispatcher
enum class NiaDispatchers {
Default,
IO,
expect abstract class DispatchersComponent {
@Provides
fun providesIODispatcher(): IODispatcher
@Provides
fun providesDefaultDispatcher(): DefaultDispatcher
}

@ -19,7 +19,7 @@ package com.google.samples.apps.nowinandroid.core.result
import app.cash.turbine.test
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
class ResultKtTest {
@ -38,7 +38,7 @@ class ResultKtTest {
when (val errorResult = awaitItem()) {
is Result.Error -> assertEquals(
"Test Done",
errorResult.exception?.message,
errorResult.exception.message,
)
Result.Loading,
is Result.Success,

@ -0,0 +1,32 @@
/*
* Copyright 2024 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.core.di
import kotlinx.coroutines.Dispatchers
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
@Component
actual abstract class DispatchersComponent {
// TODO Provides an actual IODispatcher
@Provides
actual fun providesIODispatcher(): IODispatcher = Dispatchers.Default
@Provides
actual fun providesDefaultDispatcher(): DefaultDispatcher = Dispatchers.Default
}

@ -0,0 +1,30 @@
/*
* Copyright 2024 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.core.di
import kotlinx.coroutines.Dispatchers
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
@Component
actual abstract class DispatchersComponent {
@Provides
actual fun providesIODispatcher(): IODispatcher = Dispatchers.IO
@Provides
actual fun providesDefaultDispatcher(): DefaultDispatcher = Dispatchers.Default
}

@ -1,44 +0,0 @@
/*
* 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.core.network.di
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
internal object CoroutineScopesModule {
@Provides
@Singleton
@ApplicationScope
fun providesCoroutineScope(
@Dispatcher(Default) dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
}

@ -1,39 +0,0 @@
/*
* Copyright 2022 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.core.network.di
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@Module
@InstallIn(SingletonComponent::class)
object DispatchersModule {
@Provides
@Dispatcher(IO)
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Dispatcher(Default)
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}

@ -0,0 +1,32 @@
/*
* Copyright 2024 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.core.di
import kotlinx.coroutines.Dispatchers
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
@Component
actual abstract class DispatchersComponent {
// TODO Provides an actual IODispatcher
@Provides
actual fun providesIODispatcher(): IODispatcher = Dispatchers.Default
@Provides
actual fun providesDefaultDispatcher(): DefaultDispatcher = Dispatchers.Default
}

@ -32,7 +32,9 @@ kotlin {
sourceSets {
commonMain.dependencies {
api(projects.core.model)
implementation(projects.core.common)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.sqldelight.coroutines.extensions)
implementation(libs.sqldelight.primitive.adapters)
}
@ -50,7 +52,8 @@ kotlin {
}
jsMain.dependencies {
implementation(libs.sqldelight.webworker.driver)
implementation(npm("sql.js", "1.6.2"))
implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.0.1"))
implementation(npm("sql.js", "1.8.0"))
implementation(devNpm("copy-webpack-plugin", "9.1.0"))
}
commonTest.dependencies {
@ -69,3 +72,14 @@ sqldelight {
}
}
}
// Workaround yarn concurrency issue - https://youtrack.jetbrains.com/issue/KT-43320
tasks.withType<org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinNpmInstallTask>()
.configureEach {
args.addAll(
listOf(
"--mutex",
"file:${file("../build/.yarn-mutex")}",
),
)
}

@ -0,0 +1,30 @@
const path = require("path");
const os = require("os");
const dist = path.resolve("../../node_modules/sql.js/dist/")
const wasm = path.join(dist, "sql-wasm.wasm")
config.files.push({
pattern: wasm,
served: true,
watched: false,
included: false,
nocache: false,
});
config.proxies["/sql-wasm.wasm"] = `/absolute${wasm}`
// Adapted from: https://github.com/ryanclark/karma-webpack/issues/498#issuecomment-790040818
const output = {
path: path.join(os.tmpdir(), '_karma_webpack_') + Math.floor(Math.random() * 1000000),
}
config.set({
webpack: {...config.webpack, output}
});
config.files.push({
pattern: `${output.path}/**/*`,
watched: false,
included: false,
});
// TODO: Figure out why on earth this is necessary. Presumably a karma-webpack bug???
delete config.webpack.optimization;

@ -17,21 +17,33 @@
package com.google.samples.apps.nowinandroid.core.database
import android.content.Context
import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Provides
@Inject
actual class DriverModule(private val context: Context) {
@Component
internal actual abstract class DriverModule(private val context: Context) {
@Provides
actual suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
): SqlDriver {
return AndroidSqliteDriver(schema.synchronous(), context, "nia-database.db")
val synchronousSchema = schema.synchronous()
return AndroidSqliteDriver(
schema = synchronousSchema,
context = context,
name = "nia-database.db",
callback = object : AndroidSqliteDriver.Callback(synchronousSchema) {
override fun onOpen(db: SupportSQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true)
}
},
)
}
}

@ -18,8 +18,12 @@ package com.google.samples.apps.nowinandroid.core.database
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import java.util.Properties
actual suspend fun createDriver(): SqlDriver {
return JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
return JdbcSqliteDriver(
url = JdbcSqliteDriver.IN_MEMORY,
properties = Properties().apply { put("foreign_keys", "true") },
)
.also { NiaDatabase.Schema.create(it).await() }
}

@ -22,35 +22,43 @@ import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import com.google.samples.apps.nowinandroid.core.di.IODispatcher
import kotlinx.coroutines.Dispatchers
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
internal object DatabaseModule {
@Component
internal abstract class DatabaseModule {
@Provides
fun providesNiaDatabase(driver: SqlDriver): NiaDatabase = NiaDatabase(driver)
@Provides
fun providesTopicsDao(
database: NiaDatabase,
): TopicDao = TopicDao(database, Dispatchers.Default)
dispatcher: IODispatcher,
): TopicDao = TopicDao(database, dispatcher)
@Provides
fun providesNewsResourceDao(
database: NiaDatabase,
): NewsResourceDao = NewsResourceDao(database, Dispatchers.Default)
dispatcher: IODispatcher,
): NewsResourceDao = NewsResourceDao(database, dispatcher)
@Provides
fun providesTopicFtsDao(
database: NiaDatabase,
): TopicFtsDao = TopicFtsDao(database, Dispatchers.Default)
dispatcher: IODispatcher,
): TopicFtsDao = TopicFtsDao(database, dispatcher)
@Provides
fun providesNewsResourceFtsDao(
database: NiaDatabase,
): NewsResourceFtsDao = NewsResourceFtsDao(database, Dispatchers.Default)
dispatcher: IODispatcher,
): NewsResourceFtsDao = NewsResourceFtsDao(database, dispatcher)
@Provides
fun providesRecentSearchQueryDao(
database: NiaDatabase,
): RecentSearchQueryDao = RecentSearchQueryDao(database, Dispatchers.Default)
dispatcher: IODispatcher,
): RecentSearchQueryDao = RecentSearchQueryDao(database, dispatcher)
}

@ -21,7 +21,7 @@ import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import me.tatarka.inject.annotations.Provides
expect class DriverModule {
internal expect abstract class DriverModule {
@Provides
suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,

@ -126,8 +126,12 @@ class NewsResourceDao(db: NiaDatabase, private val dispatcher: CoroutineDispatch
suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
) {
// TODO Consider removing cross references
// query.insertOrIgnoreNewsResourceTopicCrossRefs(newsResourceTopicCrossReferences)
newsResourceTopicCrossReferences.forEach {
query.insertOrIgnoreTopicCrossRefEntitiy(
news_resource_id = it.newsResourceId,
topic_id = it.topicId,
)
}
}
/**

@ -59,7 +59,7 @@ header_image_url = excluded.header_image_url,
publish_date = excluded.publish_date,
type = excluded.type;
insertOrIgnoreTopicCrossRefEntities:
insertOrIgnoreTopicCrossRefEntitiy:
INSERT OR IGNORE INTO news_resources_topics (news_resource_id, topic_id)
VALUES (?, ?);

@ -2,18 +2,18 @@ CREATE VIRTUAL TABLE news_resource_fts
USING FTS4(
news_resource_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
content TEXT NOT NULL
);
-- Triggers to keep the FTS index up to date.
CREATE TRIGGER news_resource_ai AFTER INSERT ON news_resource BEGIN
INSERT INTO news_resource_fts (rowid, news_resource_id, title, content) VALUES (new.rowid, new.id, new.title, new.content);
END;
CREATE TRIGGER news_resource_ad AFTER DELETE ON news_resource BEGIN
INSERT INTO news_resource_fts (news_resource_fts, rowid, news_resource_id, title, content) VALUES ('delete', old.rowid, old.id, old.title, old.content);
DELETE FROM news_resource_fts WHERE news_resource_id = old.id;
END;
CREATE TRIGGER news_resource_au AFTER UPDATE ON news_resource BEGIN
INSERT INTO news_resource_fts (news_resource_fts, rowid, news_resource_id, title, content) VALUES ('delete', old.rowid, old.id, old.title, old.content);
INSERT INTO news_resource_fts (rowid, news_resource_id, title, content) VALUES (new.rowid, new.id, new.title, new.content);
DELETE FROM news_resource_fts WHERE news_resource_id = old.id;
INSERT INTO news_resource_fts (news_resource_id, title, content) VALUES (new.id, new.title, new.content);
END;
insert:

@ -6,6 +6,6 @@ CREATE TABLE IF NOT EXISTS news_resources_topics (
FOREIGN KEY (topic_id) REFERENCES topic(id) ON DELETE CASCADE
);
CREATE INDEX idx_news_resource_id ON news_resource_topic(news_resource_id);
CREATE INDEX idx_news_resource_id ON news_resources_topics(news_resource_id);
CREATE INDEX idx_topic_id ON news_resource_topic(topic_id);
CREATE INDEX idx_topic_id ON news_resources_topics(topic_id);

@ -26,24 +26,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao
private lateinit var topicDao: TopicDao
@BeforeTest
fun setup() = runTest {
val db = NiaDatabase(createDriver())
newsResourceDao = NewsResourceDao(db, Dispatchers.Unconfined)
topicDao = TopicDao(db, Dispatchers.Unconfined)
}
// BeforeTest seems not working properly in Kotlin JS
// UninitializedPropertyAccessException: lateinit property newsResourceDao has not been initialized
// @BeforeTest
// fun setup() = runTest {
// val db = NiaDatabase(createDriver())
// newsResourceDao = NewsResourceDao(db, Dispatchers.Unconfined)
// topicDao = TopicDao(db, Dispatchers.Unconfined)
// }
@Test
fun newsResourceDao_fetches_items_by_descending_publish_date() = runTest {
val db = NiaDatabase(createDriver())
val newsResourceDao = NewsResourceDao(db, Dispatchers.Unconfined)
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
@ -79,6 +81,8 @@ class NewsResourceDaoTest {
@Test
fun newsResourceDao_filters_items_by_news_ids_by_descending_publish_date() = runTest {
val db = NiaDatabase(createDriver())
val newsResourceDao = NewsResourceDao(db, Dispatchers.Unconfined)
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
@ -117,6 +121,9 @@ class NewsResourceDaoTest {
@Test
fun newsResourceDao_filters_items_by_topic_ids_by_descending_publish_date() = runTest {
val db = NiaDatabase(createDriver())
val newsResourceDao = NewsResourceDao(db, Dispatchers.Unconfined)
val topicDao = TopicDao(db, Dispatchers.Unconfined)
val topicEntities = listOf(
testTopicEntity(
id = "1",
@ -177,6 +184,9 @@ class NewsResourceDaoTest {
@Test
fun newsResourceDao_filters_items_by_news_and_topic_ids_by_descending_publish_date() = runTest {
val db = NiaDatabase(createDriver())
val newsResourceDao = NewsResourceDao(db, Dispatchers.Unconfined)
val topicDao = TopicDao(db, Dispatchers.Unconfined)
val topicEntities = listOf(
testTopicEntity(
id = "1",
@ -238,42 +248,43 @@ class NewsResourceDaoTest {
}
@Test
fun newsResourceDao_deletes_items_by_ids() =
runTest {
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
millisSinceEpoch = 0,
),
testNewsResource(
id = "1",
millisSinceEpoch = 3,
),
testNewsResource(
id = "2",
millisSinceEpoch = 1,
),
testNewsResource(
id = "3",
millisSinceEpoch = 2,
),
)
newsResourceDao.upsertNewsResources(newsResourceEntities)
fun newsResourceDao_deletes_items_by_ids() = runTest {
val db = NiaDatabase(createDriver())
val newsResourceDao = NewsResourceDao(db, Dispatchers.Unconfined)
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
millisSinceEpoch = 0,
),
testNewsResource(
id = "1",
millisSinceEpoch = 3,
),
testNewsResource(
id = "2",
millisSinceEpoch = 1,
),
testNewsResource(
id = "3",
millisSinceEpoch = 2,
),
)
newsResourceDao.upsertNewsResources(newsResourceEntities)
val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 }
val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 }
newsResourceDao.deleteNewsResources(
toDelete.map(NewsResourceEntity::id),
)
newsResourceDao.deleteNewsResources(
toDelete.map(NewsResourceEntity::id),
)
assertEquals(
toKeep.map(NewsResourceEntity::id)
.toSet(),
newsResourceDao.getNewsResources().first()
.map { it.entity.id }
.toSet(),
)
}
assertEquals(
toKeep.map(NewsResourceEntity::id)
.toSet(),
newsResourceDao.getNewsResources().first()
.map { it.entity.id }
.toSet(),
)
}
}
private fun testTopicEntity(

@ -20,10 +20,12 @@ import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.worker.WebWorkerDriver
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
import org.w3c.dom.Worker
actual class DriverModule {
@Component
internal actual abstract class DriverModule {
@Provides
actual suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,

@ -20,14 +20,20 @@ import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
import java.util.Properties
actual class DriverModule {
@Component
internal actual abstract class DriverModule {
@Provides
actual suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
): SqlDriver {
return JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
return JdbcSqliteDriver(
url = JdbcSqliteDriver.IN_MEMORY,
properties = Properties().apply { put("foreign_keys", "true") },
)
.also { schema.create(it).await() }
}
}

@ -19,8 +19,12 @@ package com.google.samples.apps.nowinandroid.core.database
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase.Companion.Schema
import java.util.Properties
actual suspend fun createDriver(): SqlDriver {
return JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
return JdbcSqliteDriver(
url = JdbcSqliteDriver.IN_MEMORY,
properties = Properties().apply { put("foreign_keys", "true") },
)
.also { Schema.create(it).await() }
}

@ -21,14 +21,26 @@ import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import co.touchlab.sqliter.DatabaseConfiguration
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
actual class DriverModule {
@Component
internal actual abstract class DriverModule {
@Provides
actual suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
): SqlDriver {
return NativeSqliteDriver(schema.synchronous(), "nia-database.db")
val synchronousSchema = schema.synchronous()
return NativeSqliteDriver(
schema = synchronousSchema,
name = "nia-database.db",
onConfiguration = { config: DatabaseConfiguration ->
config.copy(
extendedConfig = DatabaseConfiguration.Extended(foreignKeyConstraints = true),
)
},
)
}
}

@ -19,8 +19,17 @@ package com.google.samples.apps.nowinandroid.core.database
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import co.touchlab.sqliter.DatabaseConfiguration
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase.Companion.Schema
actual suspend fun createDriver(): SqlDriver {
return NativeSqliteDriver(Schema.synchronous(), "nia-database-test.db")
return NativeSqliteDriver(
schema = Schema.synchronous(),
name = "nia-database-test.db",
onConfiguration = { config: DatabaseConfiguration ->
config.copy(
extendedConfig = DatabaseConfiguration.Extended(foreignKeyConstraints = true),
)
},
)
}

@ -0,0 +1,16 @@
config.resolve = {
fallback: {
fs: false,
path: false,
crypto: false,
}
};
const CopyWebpackPlugin = require('copy-webpack-plugin');
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
'../../node_modules/sql.js/dist/sql-wasm.wasm'
]
})
);

@ -21,7 +21,7 @@ import androidx.datastore.core.DataStoreFactory
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import com.google.samples.apps.nowinandroid.core.di.ApplicationScope
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent

@ -23,9 +23,9 @@ import androidx.datastore.dataStoreFile
import com.google.samples.apps.nowinandroid.core.datastore.IntToStringIdsMigration
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.core.di.ApplicationScope
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid.core.testing.di
import com.google.samples.apps.nowinandroid.core.di.DispatchersComponent
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.di.DispatchersModule
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
@ -30,7 +30,7 @@ import kotlinx.coroutines.test.TestDispatcher
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DispatchersModule::class],
replaces = [DispatchersComponent::class],
)
internal object TestDispatchersModule {
@Provides

@ -21,42 +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() = Unit
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf(
SearchResult(
topics = cachedTopics.filter {
searchQuery in it.name || searchQuery in it.shortDescription || searchQuery in it.longDescription
},
newsResources = cachedNewsResources.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)
}
/**
* Test only method to add the news resources to the stored list in memory
*/
fun addNewsResources(newsResources: List<NewsResource>) {
cachedNewsResources.addAll(newsResources)
}
override fun searchContents(searchQuery: String): Flow<SearchResult> =
combine(cachedTopics, cachedNewsResources) { topics, news ->
SearchResult(
topics = topics.filter {
searchQuery in it.name || searchQuery in it.shortDescription || searchQuery in it.longDescription
},
newsResources = news.filter {
searchQuery in it.content || searchQuery in it.title
},
)
}
override fun getSearchContentsCount(): Flow<Int> = combine(cachedTopics, cachedNewsResources) { topics, news -> topics.size + news.size }
@TestOnly
fun addTopics(topics: List<Topic>) = cachedTopics.update { it + topics }
@TestOnly
fun addNewsResources(newsResources: List<NewsResource>) =
cachedNewsResources.update { it + newsResources }
}

@ -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,14 +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
@ -60,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
@ -128,15 +126,8 @@ internal fun BookmarksScreen(
}
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
clearUndoState()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
clearUndoState()
}
when (feedState) {

@ -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()
}

@ -39,3 +39,6 @@ kotlin.code.style=official
# https://developer.android.com/build/releases/gradle-plugin#default-changes
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
# Suppress: The following Kotlin/Native targets cannot be built on this machine and are disabled
kotlin.native.ignoreDisabledTargets=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.3"
androidxMetrics = "1.0.0-alpha04"
androidxNavigation = "2.7.7"
@ -98,6 +98,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" }
@ -129,6 +130,7 @@ hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.r
hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
javax-inject = { module = "javax.inject:javax.inject", version = "1" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }

@ -2,6 +2,11 @@
# yarn lockfile v1
"@cashapp/sqldelight-sqljs-worker@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@cashapp/sqldelight-sqljs-worker/-/sqldelight-sqljs-worker-2.0.1.tgz#81964d4954fb24ba4e5019343ea20fba27a8cac4"
integrity sha512-wzOjAverdz1F8eiAI4fP3WObu1+geXbLSpX03WIXoDzmJ2XS2AByW4x1CpGnGZutX3AfjyqpcXL4wv9ZOH1aKQ==
"@colors/colors@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
@ -1789,10 +1794,10 @@ source-map@^0.6.0, source-map@^0.6.1:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
sql.js@1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-1.6.2.tgz#7fb70ff68089434826a39b8f5afb2170d682eb3f"
integrity sha512-9iucI5fXQa+Gspeqf/BNB20PxJIn5LhXDt4mjXoFPqXdR+NqtFs15SdKpSIJ6s529aGL9zFR9p2eSCIEiMsNGA==
sql.js@1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-1.8.0.tgz#cb45d957e17a2239662fe2f614c9b678990867a6"
integrity sha512-3HD8pSkZL+5YvYUI8nlvNILs61ALqq34xgmF+BHpqxe68yZIJ1H+sIVIODvni25+CcxHUxDyrTJUL0lE/m7afw==
statuses@2.0.1:
version "2.0.1"

Loading…
Cancel
Save