Merge pull request #1 from lihenggui/compose_multiplatform

WIP: Compose multiplatform support
pull/1323/head
Mercury Li 2 years ago committed by GitHub
commit 242878e50e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -110,5 +110,17 @@ gradlePlugin {
id = "nowinandroid.jvm.library"
implementationClass = "JvmLibraryConventionPlugin"
}
register("kmpLibrary") {
id = "nowinandroid.kmp.library"
implementationClass = "KmpLibraryConventionPlugin"
}
register("kotlinInject") {
id = "nowinandroid.kmp.inject"
implementationClass = "KotlinInjectConventionPlugin"
}
register("sqldelight") {
id = "nowinandroid.sqldelight"
implementationClass = "SqlDelightConventionPlugin"
}
}
}

@ -0,0 +1,43 @@
/*
* 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.
*/
import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configureKotlinMultiplatform
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class KmpLibraryConventionPlugin: Plugin<Project> {
override fun apply(target: Project) {
with(target) {
plugins.apply("com.android.library")
plugins.apply("org.jetbrains.kotlin.multiplatform")
configureKotlinMultiplatform()
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
configureFlavors(this)
configureGradleManagedDevices(this)
// The resource prefix is derived from the module name,
// so resources inside ":core:module1" must be prefixed with "core_module1_"
resourcePrefix = path.split("""\W""".toRegex()).drop(1).distinct().joinToString(separator = "_").lowercase() + "_"
}
}
}
}

@ -0,0 +1,40 @@
/*
* 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.
*/
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class KotlinInjectConventionPlugin: Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.devtools.ksp")
}
dependencies {
add("kspCommonMainMetadata", libs.findLibrary("kotlin.inject.compiler.ksp").get())
add("commonMainImplementation", libs.findLibrary("kotlin.inject.runtime").get())
// KSP will eventually have better multiplatform support and we'll be able to simply have
// `ksp libs.kotlinInject.compiler` in the dependencies block of each source set
// https://github.com/google/ksp/pull/1021
add("kspIosX64", libs.findLibrary("kotlin.inject.compiler.ksp").get())
add("kspIosArm64", libs.findLibrary("kotlin.inject.compiler.ksp").get())
add("kspIosSimulatorArm64", libs.findLibrary("kotlin.inject.compiler.ksp").get())
}
}
}
}

@ -0,0 +1,39 @@
/*
* 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.
*/
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors
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 org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.kotlin
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
class SqlDelightConventionPlugin: Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("app.cash.sqldelight")
extensions.configure<KotlinMultiplatformExtension> {
}
}
}
}

@ -0,0 +1,104 @@
/*
* 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
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.konan.target.HostManager
/**
* A plugin that applies the Kotlin Multiplatform plugin and configures it for the project.
* https://github.com/cashapp/sqldelight/blob/master/buildLogic/multiplatform-convention/src/main/kotlin/app/cash/sqldelight/multiplatform/MultiplatformConventions.kt
*/
internal fun Project.configureKotlinMultiplatform() {
extensions.configure<KotlinMultiplatformExtension> {
// Enable native group by default
// https://kotlinlang.org/docs/whatsnew1820.html#new-approach-to-source-set-hierarchy
applyDefaultHierarchyTemplate()
jvm()
androidTarget()
js {
browser {
testTask {
useKarma {
useChromeHeadless()
}
}
}
compilations.configureEach {
kotlinOptions {
moduleKind = "umd"
}
}
}
// tier 1
linuxX64()
macosX64()
macosArm64()
iosSimulatorArm64()
iosX64()
// Fix :core:database:linuxArm64Main: Could not resolve me.tatarka.inject:kotlin-inject-runtime:0.6.3.
// // tier 2
// linuxArm64()
// watchosSimulatorArm64()
// watchosX64()
// watchosArm32()
// watchosArm64()
// tvosSimulatorArm64()
// tvosX64()
// tvosArm64()
iosArm64()
// fix :core:model:androidNativeArm32Main: Could not resolve org.jetbrains.kotlinx:kotlinx-datetime
// // tier 3
// androidNativeArm32()
// androidNativeArm64()
// androidNativeX86()
// androidNativeX64()
// mingwX64()
// watchosDeviceArm64()
// linking fails for the linux test build if not built on a linux host
// ensure the tests and linking for them is only done on linux hosts
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",
)
}
}
// Fixes Cannot locate tasks that match ':core:model:testClasses' as task 'testClasses'
// not found in project ':core:model'. Some candidates are: 'jsTestClasses', 'jvmTestClasses'.
project.tasks.create("testClasses") {
dependsOn("allTests")
}
}
}

@ -46,4 +46,7 @@ plugins {
alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.secrets) apply false
alias(libs.plugins.room) apply false
alias(libs.plugins.jetbrainsCompose) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.sqldelight.gradle.plugin) apply false
}

@ -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.
@ -15,10 +15,9 @@
*/
plugins {
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.nowinandroid.android.hilt)
alias(libs.plugins.nowinandroid.android.room)
alias(libs.plugins.nowinandroid.kmp.library)
alias(libs.plugins.nowinandroid.kotlin.inject)
alias(libs.plugins.sqldelight.gradle.plugin)
}
android {
@ -29,10 +28,44 @@ android {
namespace = "com.google.samples.apps.nowinandroid.core.database"
}
dependencies {
api(projects.core.model)
implementation(libs.kotlinx.datetime)
kotlin {
sourceSets {
commonMain.dependencies {
api(projects.core.model)
implementation(libs.kotlinx.datetime)
implementation(libs.sqldelight.coroutines.extensions)
implementation(libs.sqldelight.primitive.adapters)
}
androidMain.dependencies {
implementation(libs.sqldelight.android.driver)
}
androidUnitTest.dependencies {
implementation(libs.sqldelight.sqlite.driver)
}
nativeMain.dependencies {
implementation(libs.sqldelight.native.driver)
}
jvmMain.dependencies {
implementation(libs.sqldelight.sqlite.driver)
}
jsMain.dependencies {
implementation(libs.sqldelight.webworker.driver)
implementation(npm("sql.js", "1.6.2"))
implementation(devNpm("copy-webpack-plugin", "9.1.0"))
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}
androidTestImplementation(projects.core.testing)
sqldelight {
databases {
create("NiaDatabase") {
packageName.set("com.google.samples.apps.nowinandroid.core.database")
generateAsync.set(true)
dialect("app.cash.sqldelight:sqlite-3-38-dialect:2.0.1")
}
}
}

@ -0,0 +1,37 @@
/*
* 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.database
import android.content.Context
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.Inject
import me.tatarka.inject.annotations.Provides
@Inject
actual 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")
}
}

@ -0,0 +1,25 @@
/*
* 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.database
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
actual suspend fun createDriver(): SqlDriver {
return JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
.also { NiaDatabase.Schema.create(it).await() }
}

@ -16,41 +16,41 @@
package com.google.samples.apps.nowinandroid.core.database
import app.cash.sqldelight.db.SqlDriver
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
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 dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal object DaosModule {
import kotlinx.coroutines.Dispatchers
import me.tatarka.inject.annotations.Provides
internal object DatabaseModule {
@Provides
fun providesNiaDatabase(driver: SqlDriver): NiaDatabase = NiaDatabase(driver)
@Provides
fun providesTopicsDao(
database: NiaDatabase,
): TopicDao = database.topicDao()
): TopicDao = TopicDao(database, Dispatchers.Default)
@Provides
fun providesNewsResourceDao(
database: NiaDatabase,
): NewsResourceDao = database.newsResourceDao()
): NewsResourceDao = NewsResourceDao(database, Dispatchers.Default)
@Provides
fun providesTopicFtsDao(
database: NiaDatabase,
): TopicFtsDao = database.topicFtsDao()
): TopicFtsDao = TopicFtsDao(database, Dispatchers.Default)
@Provides
fun providesNewsResourceFtsDao(
database: NiaDatabase,
): NewsResourceFtsDao = database.newsResourceFtsDao()
): NewsResourceFtsDao = NewsResourceFtsDao(database, Dispatchers.Default)
@Provides
fun providesRecentSearchQueryDao(
database: NiaDatabase,
): RecentSearchQueryDao = database.recentSearchQueryDao()
): RecentSearchQueryDao = RecentSearchQueryDao(database, Dispatchers.Default)
}

@ -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.
@ -16,25 +16,14 @@
package com.google.samples.apps.nowinandroid.core.database
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import me.tatarka.inject.annotations.Provides
@Module
@InstallIn(SingletonComponent::class)
internal object DatabaseModule {
expect class DriverModule {
@Provides
@Singleton
fun providesNiaDatabase(
@ApplicationContext context: Context,
): NiaDatabase = Room.databaseBuilder(
context,
NiaDatabase::class.java,
"nia-database",
).build()
suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
): SqlDriver
}

@ -0,0 +1,139 @@
/*
* 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.database.dao
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
/**
* DAO for [NewsResource] and [NewsResourceEntity] access
*/
class NewsResourceDao(db: NiaDatabase, private val dispatcher: CoroutineDispatcher) {
private val query = db.newsResourceQueries
/**
* Fetches news resources that match the query parameters
*/
fun getNewsResources(
useFilterTopicIds: Boolean = false,
filterTopicIds: Set<String> = emptySet(),
useFilterNewsIds: Boolean = false,
filterNewsIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>> {
return query.getNewsResources(
useFilterTopicIds = useFilterTopicIds,
filterTopicIds = filterTopicIds,
useFilterNewsIds = useFilterNewsIds,
filterNewsIds = filterNewsIds,
) { id, title, content, url, headerImageUrl, publishDate, type ->
PopulatedNewsResource(
entity = NewsResourceEntity(
id = id,
title = title,
content = content,
url = url,
headerImageUrl = headerImageUrl,
publishDate = Instant.fromEpochMilliseconds(publishDate),
type = type,
),
// TODO Dealing with NewsResources <-> Topics relationship
topics = emptyList(),
)
}
.asFlow()
.mapToList(dispatcher)
}
/**
* Fetches ids of news resources that match the query parameters
*/
fun getNewsResourceIds(
useFilterTopicIds: Boolean = false,
filterTopicIds: Set<String> = emptySet(),
useFilterNewsIds: Boolean = false,
filterNewsIds: Set<String> = emptySet(),
): Flow<List<String>> {
return query.getNewsResourceIds(
useFilterTopicIds = useFilterTopicIds,
filterTopicIds = filterTopicIds,
useFilterNewsIds = useFilterNewsIds,
filterNewsIds = filterNewsIds,
)
.asFlow()
.mapToList(dispatcher)
}
/**
* Inserts [entities] into the db if they don't exist, and ignores those that do
*/
suspend fun insertOrIgnoreNewsResources(entities: List<NewsResourceEntity>): List<Long> {
entities.forEach {
query.insertOrIgnoreNewsResource(
id = it.id,
title = it.title,
content = it.content,
url = it.url,
header_image_url = it.headerImageUrl,
publish_date = it.publishDate.toEpochMilliseconds(),
type = it.type,
)
}
// TODO Return the inserted ids
return entities.mapNotNull {
it.id.toLongOrNull()
}
}
/**
* Inserts or updates [newsResourceEntities] in the db under the specified primary keys
*/
suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
newsResourceEntities.forEach {
query.upsertNewsResource(
id = it.id,
title = it.title,
content = it.content,
url = it.url,
header_image_url = it.headerImageUrl,
publish_date = it.publishDate.toEpochMilliseconds(),
type = it.type,
)
}
}
suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
) {
// TODO Consider removing cross references
// query.insertOrIgnoreNewsResourceTopicCrossRefs(newsResourceTopicCrossReferences)
}
/**
* Deletes rows in the db matching the specified [ids]
*/
suspend fun deleteNewsResources(ids: List<String>) {
query.deleteNewsResources(ids)
}
}

@ -16,24 +16,40 @@
package com.google.samples.apps.nowinandroid.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOneNotNull
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* DAO for [NewsResourceFtsEntity] access.
*/
@Dao
interface NewsResourceFtsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(newsResources: List<NewsResourceFtsEntity>)
class NewsResourceFtsDao(db: NiaDatabase, private val dispatcher: CoroutineDispatcher) {
private val dbQuery = db.newsResourceFtsQueries
suspend fun insertAll(newsResources: List<NewsResourceFtsEntity>) {
newsResources.forEach {
dbQuery.insert(
news_resource_id = it.newsResourceId,
title = it.title,
content = it.content,
)
}
}
@Query("SELECT newsResourceId FROM newsResourcesFts WHERE newsResourcesFts MATCH :query")
fun searchAllNewsResources(query: String): Flow<List<String>>
fun searchAllNewsResources(query: String): Flow<List<String>> {
return dbQuery.searchAllNewsResources(query)
.asFlow()
.mapToList(dispatcher)
}
@Query("SELECT count(*) FROM newsResourcesFts")
fun getCount(): Flow<Int>
fun getCount(): Flow<Int> {
return dbQuery.getCount()
.asFlow()
.mapToOneNotNull(dispatcher)
.map { it.toInt() }
}
}

@ -0,0 +1,58 @@
/*
* 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.database.dao
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.Recent_search_query
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
/**
* DAO for [RecentSearchQueryEntity] access
*/
class RecentSearchQueryDao(db: NiaDatabase, private val dispatcher: CoroutineDispatcher) {
private val query = db.recentSearchQueryQueries
fun getRecentSearchQueryEntities(limit: Int): Flow<List<RecentSearchQueryEntity>> {
return query.getRecentSearchQueryEntities(limit.toLong()) { query, timestamp ->
RecentSearchQueryEntity(
query = query,
queriedDate = Instant.fromEpochMilliseconds(timestamp ?: 0L),
)
}
.asFlow()
.mapToList(dispatcher)
}
suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) {
query.insertOrReplaceRecentSearchQuery(
recent_search_query = Recent_search_query(
query = recentSearchQuery.query,
queried_date = recentSearchQuery.queriedDate.toEpochMilliseconds(),
),
)
}
suspend fun clearRecentSearchQueries() {
query.clearRecentSearchQueries()
}
}

@ -0,0 +1,138 @@
/*
* 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.database.dao
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOne
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* DAO for [TopicEntity] access
*/
class TopicDao(db: NiaDatabase, private val dispatcher: CoroutineDispatcher) {
private val query = db.topicsQueries
fun getTopicEntity(topicId: String): Flow<TopicEntity> {
return query.getTopicEntity(topicId) { id, name, shortDescription, longDescription, url, imageUrl ->
TopicEntity(
id = id,
name = name,
shortDescription = shortDescription,
longDescription = longDescription,
url = url,
imageUrl = imageUrl,
)
}
.asFlow()
.mapToOne(dispatcher)
}
fun getTopicEntities(): Flow<List<TopicEntity>> {
return query.getOneOffTopicEntities { id, name, shortDescription, longDescription, url, imageUrl ->
TopicEntity(
id = id,
name = name,
shortDescription = shortDescription,
longDescription = longDescription,
url = url,
imageUrl = imageUrl,
)
}
.asFlow()
.mapToList(dispatcher)
}
suspend fun getOneOffTopicEntities(): List<TopicEntity> {
// TODO: Use flow?
return query.getOneOffTopicEntities { id, name, shortDescription, longDescription, url, imageUrl ->
TopicEntity(
id = id,
name = name,
shortDescription = shortDescription,
longDescription = longDescription,
url = url,
imageUrl = imageUrl,
)
}.executeAsList()
}
fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> {
return query.getTopicEntities { id, name, shortDescription, longDescription, url, imageUrl ->
TopicEntity(
id = id,
name = name,
shortDescription = shortDescription,
longDescription = longDescription,
url = url,
imageUrl = imageUrl,
)
}
.asFlow()
.mapToList(dispatcher)
.map {
it.filter { topic -> topic.id in ids }
}
}
/**
* Inserts [topicEntities] into the db if they don't exist, and ignores those that do
*/
suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> {
topicEntities.map {
query.insertOrIgnoreTopic(
id = it.id,
name = it.name,
short_description = it.shortDescription,
long_description = it.longDescription,
url = it.url,
image_url = it.imageUrl,
)
}
// TODO return the ids of the inserted topics
return topicEntities.mapNotNull { it.id.toLongOrNull() }
}
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
suspend fun upsertTopics(entities: List<TopicEntity>) {
entities.forEach {
query.upsertTopic(
id = it.id,
name = it.name,
short_description = it.shortDescription,
long_description = it.longDescription,
url = it.url,
image_url = it.imageUrl,
)
}
}
/**
* Deletes rows in the db matching the specified [ids]
*/
suspend fun deleteTopics(ids: List<String>) {
query.deleteTopics(ids)
}
}

@ -0,0 +1,60 @@
/*
* 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.database.dao
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOne
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* DAO for [TopicFtsEntity] access.
*/
class TopicFtsDao(db: NiaDatabase, private val dispatcher: CoroutineDispatcher) {
private val dbQuery = db.topicFtsQueries
suspend fun insertAll(topics: List<TopicFtsEntity>) {
topics.forEach {
dbQuery.insert(
topic_id = it.topicId,
name = it.name,
short_description = it.shortDescription,
long_description = it.longDescription,
)
}
}
fun searchAllTopics(query: String): Flow<List<String>> {
return dbQuery.searchAllTopics(query) {
it.orEmpty()
}
.asFlow()
.mapToList(dispatcher)
}
fun getCount(): Flow<Int> {
return dbQuery.getCount()
.asFlow()
.mapToOne(dispatcher)
.map { it.toInt() }
}
}

@ -16,27 +16,18 @@
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import kotlinx.datetime.Instant
/**
* Defines an NiA news resource.
*/
@Entity(
tableName = "news_resources",
)
data class NewsResourceEntity(
@PrimaryKey
val id: String,
val title: String,
val content: String,
val url: String,
@ColumnInfo(name = "header_image_url")
val headerImageUrl: String?,
@ColumnInfo(name = "publish_date")
val publishDate: Instant,
val type: String,
)

@ -16,23 +16,11 @@
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
/**
* Fts entity for the news resources. See https://developer.android.com/reference/androidx/room/Fts4.
*/
@Entity(tableName = "newsResourcesFts")
@Fts4
data class NewsResourceFtsEntity(
@ColumnInfo(name = "newsResourceId")
val newsResourceId: String,
@ColumnInfo(name = "title")
val title: String,
@ColumnInfo(name = "content")
val content: String,
)

@ -16,39 +16,10 @@
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
/**
* Cross reference for many to many relationship between [NewsResourceEntity] and [TopicEntity]
*/
@Entity(
tableName = "news_resources_topics",
primaryKeys = ["news_resource_id", "topic_id"],
foreignKeys = [
ForeignKey(
entity = NewsResourceEntity::class,
parentColumns = ["id"],
childColumns = ["news_resource_id"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = TopicEntity::class,
parentColumns = ["id"],
childColumns = ["topic_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["news_resource_id"]),
Index(value = ["topic_id"]),
],
)
data class NewsResourceTopicCrossRef(
@ColumnInfo(name = "news_resource_id")
val newsResourceId: String,
@ColumnInfo(name = "topic_id")
val topicId: String,
)

@ -16,26 +16,13 @@
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
/**
* External data layer representation of a fully populated NiA news resource
*/
data class PopulatedNewsResource(
@Embedded
val entity: NewsResourceEntity,
@Relation(
parentColumn = "id",
entityColumn = "id",
associateBy = Junction(
value = NewsResourceTopicCrossRef::class,
parentColumn = "news_resource_id",
entityColumn = "topic_id",
),
)
val topics: List<TopicEntity>,
)

@ -16,20 +16,12 @@
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* Defines an database entity that stored recent search queries.
*/
@Entity(
tableName = "recentSearchQueries",
)
data class RecentSearchQueryEntity(
@PrimaryKey
val query: String,
@ColumnInfo
val queriedDate: Instant,
)

@ -16,28 +16,18 @@
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.samples.apps.nowinandroid.core.model.data.Topic
/**
* Defines a topic a user may follow.
* It has a many to many relationship with [NewsResourceEntity]
*/
@Entity(
tableName = "topics",
)
data class TopicEntity(
@PrimaryKey
val id: String,
val name: String,
val shortDescription: String,
@ColumnInfo(defaultValue = "")
val longDescription: String,
@ColumnInfo(defaultValue = "")
val url: String,
@ColumnInfo(defaultValue = "")
val imageUrl: String,
)

@ -16,27 +16,13 @@
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
/**
* Fts entity for the topic. See https://developer.android.com/reference/androidx/room/Fts4.
*/
@Entity(tableName = "topicsFts")
@Fts4
data class TopicFtsEntity(
@ColumnInfo(name = "topicId")
val topicId: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "shortDescription")
val shortDescription: String,
@ColumnInfo(name = "longDescription")
val longDescription: String,
)

@ -16,15 +16,13 @@
package com.google.samples.apps.nowinandroid.core.database.util
import androidx.room.TypeConverter
import kotlinx.datetime.Instant
internal class InstantConverter {
@TypeConverter
fun longToInstant(value: Long?): Instant? =
value?.let(Instant::fromEpochMilliseconds)
@TypeConverter
fun instantToLong(instant: Instant?): Long? =
instant?.toEpochMilliseconds()
}

@ -0,0 +1,68 @@
CREATE TABLE news_resource (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
url TEXT NOT NULL,
header_image_url TEXT,
publish_date INTEGER NOT NULL,
type TEXT NOT NULL
);
getNewsResources:
SELECT * FROM news_resource
WHERE
CASE WHEN :useFilterNewsIds
THEN id IN :filterNewsIds
ELSE 1
END
AND
CASE WHEN :useFilterTopicIds
THEN id IN
(
SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN :filterTopicIds
)
ELSE 1
END
ORDER BY publish_date DESC;
getNewsResourceIds:
SELECT id FROM news_resource
WHERE
CASE WHEN :useFilterNewsIds
THEN id IN :filterNewsIds
ELSE 1
END
AND
CASE WHEN :useFilterTopicIds
THEN id IN
(
SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN :filterTopicIds
)
ELSE 1
END
ORDER BY publish_date DESC;
insertOrIgnoreNewsResource:
INSERT OR IGNORE INTO news_resource (id, title, content, url, header_image_url, publish_date, type)
VALUES (?, ?, ?, ?, ?, ?, ?);
upsertNewsResource:
INSERT INTO news_resource (id, title, content, url, header_image_url, publish_date, type)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
content = excluded.content,
url = excluded.url,
header_image_url = excluded.header_image_url,
publish_date = excluded.publish_date,
type = excluded.type;
insertOrIgnoreTopicCrossRefEntities:
INSERT OR IGNORE INTO news_resources_topics (news_resource_id, topic_id)
VALUES (?, ?);
deleteNewsResources:
DELETE FROM news_resource
WHERE id IN :ids;

@ -0,0 +1,26 @@
CREATE VIRTUAL TABLE news_resource_fts
USING FTS4(
news_resource_id TEXT NOT NULL,
title 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);
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);
END;
insert:
INSERT INTO news_resource_fts (news_resource_id, title, content) VALUES (:news_resource_id, :title, :content);
searchAllNewsResources:
SELECT news_resource_id FROM news_resource_fts WHERE news_resource_fts.title MATCH :query;
getCount:
SELECT count(*) FROM news_resource_fts;

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS news_resource_topic (
news_resource_id TEXT NOT NULL,
topic_id TEXT NOT NULL,
PRIMARY KEY(news_resource_id, topic_id),
FOREIGN KEY(news_resource_id) REFERENCES news_resource(id) ON DELETE CASCADE,
FOREIGN KEY(topic_id) REFERENCES topic(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS index_news_resource_topic_news_resource_id ON news_resource_topic(news_resource_id);
CREATE INDEX IF NOT EXISTS index_news_resource_topic_topic_id ON news_resource_topic(topic_id);

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS news_resources_topics (
news_resource_id TEXT NOT NULL,
topic_id TEXT NOT NULL,
PRIMARY KEY (news_resource_id, topic_id),
FOREIGN KEY (news_resource_id) REFERENCES news_resource(id) ON DELETE CASCADE,
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_topic_id ON news_resource_topic(topic_id);

@ -0,0 +1,13 @@
CREATE TABLE recent_search_query (
query TEXT NOT NULL PRIMARY KEY,
queried_date INTEGER DEFAULT CURRENT_TIMESTAMP
);
getRecentSearchQueryEntities:
SELECT * FROM recent_search_query ORDER BY queried_date DESC LIMIT :limit;
insertOrReplaceRecentSearchQuery:
INSERT OR REPLACE INTO recent_search_query (query) VALUES :query;
clearRecentSearchQueries:
DELETE FROM recent_search_query;

@ -0,0 +1,41 @@
CREATE VIRTUAL TABLE IF NOT EXISTS topic_fts
USING FTS4(
topic_id TEXT,
name TEXT,
short_description TEXT,
long_description TEXT
);
CREATE TRIGGER IF NOT EXISTS topic_ai AFTER INSERT ON topic
BEGIN
INSERT INTO topic_fts (topic_id, name, short_description, long_description)
VALUES (new.id, new.name, new.short_description, new.long_description);
END;
CREATE TRIGGER IF NOT EXISTS topic_ad AFTER DELETE ON topic
BEGIN
DELETE FROM topic_fts WHERE topic_id = old.id;
END;
CREATE TRIGGER IF NOT EXISTS topic_au AFTER UPDATE ON topic
BEGIN
UPDATE topic_fts SET
name = new.name,
short_description = new.short_description,
long_description = new.long_description
WHERE topic_id = new.id;
END;
insert:
INSERT INTO topic_fts (topic_id, name, short_description, long_description)
VALUES (?, ?, ?, ?)
ON CONFLICT(topic_id) DO UPDATE SET
name = excluded.name,
short_description = excluded.short_description,
long_description = excluded.long_description;
searchAllTopics:
SELECT topic_id FROM topic_fts WHERE topic_fts MATCH :query;
getCount:
SELECT count(*) FROM topic_fts;

@ -0,0 +1,34 @@
CREATE TABLE topic (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
short_description TEXT NOT NULL,
long_description TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT ''
);
getTopicEntity:
SELECT * FROM topic WHERE id = :topicId;
getTopicEntities:
SELECT * FROM topic;
getOneOffTopicEntities:
SELECT * FROM topic;
insertOrIgnoreTopic:
INSERT OR IGNORE INTO topic(id, name, short_description, long_description, url, image_url)
VALUES (?, ?, ?, ?, ?, ?);
upsertTopic:
INSERT INTO topic(id, name, short_description, long_description, url, image_url)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
short_description = excluded.short_description,
long_description = excluded.long_description,
url = excluded.url,
image_url = excluded.image_url;
deleteTopics:
DELETE FROM topic WHERE id IN :ids;

@ -0,0 +1,31 @@
/*
* 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.database
import app.cash.sqldelight.db.SqlDriver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
/**
* Init driver for each platform. Should *always* be called to setup test
*/
expect suspend fun createDriver(): SqlDriver
fun testing(block: suspend CoroutineScope.(NiaDatabase) -> Unit) = runTest {
val driver = createDriver()
block(NiaDatabase(driver))
driver.close()
}

@ -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.
@ -16,36 +16,30 @@
package com.google.samples.apps.nowinandroid.core.database.dao
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.createDriver
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Test
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao
private lateinit var topicDao: TopicDao
private lateinit var db: NiaDatabase
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context,
NiaDatabase::class.java,
).build()
newsResourceDao = db.newsResourceDao()
topicDao = db.topicDao()
@BeforeTest
fun setup() = runTest {
val db = NiaDatabase(createDriver())
newsResourceDao = NewsResourceDao(db, Dispatchers.Unconfined)
topicDao = TopicDao(db, Dispatchers.Unconfined)
}
@Test

@ -0,0 +1,37 @@
/*
* 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.database
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.Provides
import org.w3c.dom.Worker
actual class DriverModule {
@Provides
actual suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
): SqlDriver {
return WebWorkerDriver(
Worker(
js("""new URL("@cashapp/sqldelight-sqljs-worker/sqljs.worker.js", import.meta.url)"""),
),
).also { schema.create(it).await() }
}
}

@ -0,0 +1,29 @@
/*
* 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.database
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.worker.WebWorkerDriver
import org.w3c.dom.Worker
actual suspend fun createDriver(): SqlDriver {
return WebWorkerDriver(
Worker(
js("""new URL("@cashapp/sqldelight-sqljs-worker/sqljs.worker.js", import.meta.url)"""),
),
).also { NiaDatabase.Schema.create(it).await() }
}

@ -0,0 +1,33 @@
/*
* 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.database
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.Provides
actual class DriverModule {
@Provides
actual suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
): SqlDriver {
return JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
.also { schema.create(it).await() }
}
}

@ -0,0 +1,26 @@
/*
* 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.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
actual suspend fun createDriver(): SqlDriver {
return JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
.also { Schema.create(it).await() }
}

@ -1,63 +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.database
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RenameColumn
import androidx.room.migration.AutoMigrationSpec
/**
* Automatic schema migrations sometimes require extra instructions to perform the migration, for
* example, when a column is renamed. These extra instructions are placed here by creating a class
* using the following naming convention `SchemaXtoY` where X is the schema version you're migrating
* from and Y is the schema version you're migrating to. The class should implement
* `AutoMigrationSpec`.
*/
internal object DatabaseMigrations {
@RenameColumn(
tableName = "topics",
fromColumnName = "description",
toColumnName = "shortDescription",
)
class Schema2to3 : AutoMigrationSpec
@DeleteColumn(
tableName = "news_resources",
columnName = "episode_id",
)
@DeleteTable.Entries(
DeleteTable(
tableName = "episodes_authors",
),
DeleteTable(
tableName = "episodes",
),
)
class Schema10to11 : AutoMigrationSpec
@DeleteTable.Entries(
DeleteTable(
tableName = "news_resources_authors",
),
DeleteTable(
tableName = "authors",
),
)
class Schema11to12 : AutoMigrationSpec
}

@ -1,72 +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.database
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
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.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter
@Database(
entities = [
NewsResourceEntity::class,
NewsResourceTopicCrossRef::class,
NewsResourceFtsEntity::class,
TopicEntity::class,
TopicFtsEntity::class,
RecentSearchQueryEntity::class,
],
version = 14,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class),
AutoMigration(from = 12, to = 13),
AutoMigration(from = 13, to = 14),
],
exportSchema = true,
)
@TypeConverters(
InstantConverter::class,
)
internal abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao
abstract fun newsResourceDao(): NewsResourceDao
abstract fun topicFtsDao(): TopicFtsDao
abstract fun newsResourceFtsDao(): NewsResourceFtsDao
abstract fun recentSearchQueryDao(): RecentSearchQueryDao
}

@ -1,126 +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.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import kotlinx.coroutines.flow.Flow
/**
* DAO for [NewsResource] and [NewsResourceEntity] access
*/
@Dao
interface NewsResourceDao {
/**
* Fetches news resources that match the query parameters
*/
@Transaction
@Query(
value = """
SELECT * FROM news_resources
WHERE
CASE WHEN :useFilterNewsIds
THEN id IN (:filterNewsIds)
ELSE 1
END
AND
CASE WHEN :useFilterTopicIds
THEN id IN
(
SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN (:filterTopicIds)
)
ELSE 1
END
ORDER BY publish_date DESC
""",
)
fun getNewsResources(
useFilterTopicIds: Boolean = false,
filterTopicIds: Set<String> = emptySet(),
useFilterNewsIds: Boolean = false,
filterNewsIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>>
/**
* Fetches ids of news resources that match the query parameters
*/
@Transaction
@Query(
value = """
SELECT id FROM news_resources
WHERE
CASE WHEN :useFilterNewsIds
THEN id IN (:filterNewsIds)
ELSE 1
END
AND
CASE WHEN :useFilterTopicIds
THEN id IN
(
SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN (:filterTopicIds)
)
ELSE 1
END
ORDER BY publish_date DESC
""",
)
fun getNewsResourceIds(
useFilterTopicIds: Boolean = false,
filterTopicIds: Set<String> = emptySet(),
useFilterNewsIds: Boolean = false,
filterNewsIds: Set<String> = emptySet(),
): Flow<List<String>>
/**
* Inserts [entities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreNewsResources(entities: List<NewsResourceEntity>): List<Long>
/**
* Inserts or updates [newsResourceEntities] in the db under the specified primary keys
*/
@Upsert
suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
)
/**
* Deletes rows in the db matching the specified [ids]
*/
@Query(
value = """
DELETE FROM news_resources
WHERE id in (:ids)
""",
)
suspend fun deleteNewsResources(ids: List<String>)
}

@ -1,38 +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.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [RecentSearchQueryEntity] access
*/
@Dao
interface RecentSearchQueryDao {
@Query(value = "SELECT * FROM recentSearchQueries ORDER BY queriedDate DESC LIMIT :limit")
fun getRecentSearchQueryEntities(limit: Int): Flow<List<RecentSearchQueryEntity>>
@Upsert
suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity)
@Query(value = "DELETE FROM recentSearchQueries")
suspend fun clearRecentSearchQueries()
}

@ -1,76 +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.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [TopicEntity] access
*/
@Dao
interface TopicDao {
@Query(
value = """
SELECT * FROM topics
WHERE id = :topicId
""",
)
fun getTopicEntity(topicId: String): Flow<TopicEntity>
@Query(value = "SELECT * FROM topics")
fun getTopicEntities(): Flow<List<TopicEntity>>
@Query(value = "SELECT * FROM topics")
suspend fun getOneOffTopicEntities(): List<TopicEntity>
@Query(
value = """
SELECT * FROM topics
WHERE id IN (:ids)
""",
)
fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>>
/**
* Inserts [topicEntities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long>
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
@Upsert
suspend fun upsertTopics(entities: List<TopicEntity>)
/**
* Deletes rows in the db matching the specified [ids]
*/
@Query(
value = """
DELETE FROM topics
WHERE id in (:ids)
""",
)
suspend fun deleteTopics(ids: List<String>)
}

@ -1,39 +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.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [TopicFtsEntity] access.
*/
@Dao
interface TopicFtsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(topics: List<TopicFtsEntity>)
@Query("SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query")
fun searchAllTopics(query: String): Flow<List<String>>
@Query("SELECT count(*) FROM topicsFts")
fun getCount(): Flow<Int>
}

@ -0,0 +1,34 @@
/*
* 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.database
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.native.NativeSqliteDriver
import me.tatarka.inject.annotations.Provides
actual class DriverModule {
@Provides
actual suspend fun provideDbDriver(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
): SqlDriver {
return NativeSqliteDriver(schema.synchronous(), "nia-database.db")
}
}

@ -0,0 +1,26 @@
/*
* 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.database
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase.Companion.Schema
actual suspend fun createDriver(): SqlDriver {
return NativeSqliteDriver(Schema.synchronous(), "nia-database-test.db")
}

@ -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.
@ -15,9 +15,20 @@
*/
plugins {
alias(libs.plugins.nowinandroid.jvm.library)
alias(libs.plugins.nowinandroid.kmp.library)
}
dependencies {
api(libs.kotlinx.datetime)
android {
namespace = "com.google.samples.apps.nowinandroid.core.model"
}
kotlin {
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.datetime)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}

@ -56,6 +56,22 @@ room = "2.6.1"
secrets = "2.0.1"
truth = "1.1.5"
turbine = "1.0.0"
agp = "8.2.0"
android-compileSdk = "34"
android-minSdk = "24"
android-targetSdk = "34"
androidx-activityCompose = "1.8.2"
androidx-appcompat = "1.6.1"
androidx-constraintlayout = "2.1.4"
androidx-core-ktx = "1.12.0"
androidx-espresso-core = "3.5.1"
androidx-material = "1.11.0"
androidx-test-junit = "1.1.5"
compose = "1.6.0"
compose-plugin = "1.6.0-alpha01"
junit = "4.13.2"
sqldelight = "2.0.1"
kotlinInject = '0.6.3'
[libraries]
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
@ -133,6 +149,23 @@ room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
sqldelight-android-driver = { group = "app.cash.sqldelight", name = "android-driver", version.ref = "sqldelight" }
sqldelight-native-driver = { group = "app.cash.sqldelight", name = "native-driver", version.ref = "sqldelight" }
sqldelight-sqlite-driver = { group = "app.cash.sqldelight", name = "sqlite-driver", version.ref = "sqldelight" }
sqldelight-webworker-driver = { group = "app.cash.sqldelight", name = "web-worker-driver", version.ref = "sqldelight" }
sqldelight-coroutines-extensions = { group = "app.cash.sqldelight", name = "coroutines-extensions", version.ref = "sqldelight" }
sqldelight-primitive-adapters = { group = "app.cash.sqldelight", name = "primitive-adapters", version.ref = "sqldelight" }
kotlin-inject-compiler-ksp = { group = "me.tatarka.inject", name = "kotlin-inject-compiler-ksp", version.ref = "kotlinInject" }
kotlin-inject-runtime = { group = "me.tatarka.inject", name = "kotlin-inject-runtime", version.ref = "kotlinInject" }
# Dependencies of the included build-logic
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
@ -161,6 +194,9 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
room = { id = "androidx.room", version.ref = "room" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
sqldelight-gradle-plugin = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
# Plugins defined by this project
nowinandroid-android-application = { id = "nowinandroid.android.application", version = "unspecified" }
@ -177,3 +213,7 @@ nowinandroid-android-lint = { id = "nowinandroid.android.lint", version = "unspe
nowinandroid-android-room = { id = "nowinandroid.android.room", version = "unspecified" }
nowinandroid-android-test = { id = "nowinandroid.android.test", version = "unspecified" }
nowinandroid-jvm-library = { id = "nowinandroid.jvm.library", version = "unspecified" }
nowinandroid-kmp-library = { id = "nowinandroid.kmp.library", version = "unspecified" }
nowinandroid-kotlin-inject = { id = "nowinandroid.kmp.inject", version = "unspecified" }
nowinandroid-sqldelight = { id = "nowinandroid.sqldelight", version = "unspecified" }

@ -0,0 +1,3 @@
TEAM_ID=
BUNDLE_ID=com.google.samples.apps.nowinandroid
APP_NAME=nowinandroid

@ -0,0 +1,383 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 50;
objects = {
/* Begin PBXBuildFile section */
058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
7555FF7B242A565900829871 /* .app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = .app; sourceTree = BUILT_PRODUCTS_DIR; };
7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
058557D7273AAEEB004C7B11 /* Preview Content */ = {
isa = PBXGroup;
children = (
058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
42799AB246E5F90AF97AA0EF /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
7555FF72242A565900829871 = {
isa = PBXGroup;
children = (
AB1DB47929225F7C00F7AF9C /* Configuration */,
7555FF7D242A565900829871 /* iosApp */,
7555FF7C242A565900829871 /* Products */,
42799AB246E5F90AF97AA0EF /* Frameworks */,
);
sourceTree = "<group>";
};
7555FF7C242A565900829871 /* Products */ = {
isa = PBXGroup;
children = (
7555FF7B242A565900829871 /* .app */,
);
name = Products;
sourceTree = "<group>";
};
7555FF7D242A565900829871 /* iosApp */ = {
isa = PBXGroup;
children = (
058557BA273AAA24004C7B11 /* Assets.xcassets */,
7555FF82242A565900829871 /* ContentView.swift */,
7555FF8C242A565B00829871 /* Info.plist */,
2152FB032600AC8F00CF470E /* iOSApp.swift */,
058557D7273AAEEB004C7B11 /* Preview Content */,
);
path = iosApp;
sourceTree = "<group>";
};
AB1DB47929225F7C00F7AF9C /* Configuration */ = {
isa = PBXGroup;
children = (
AB3632DC29227652001CCB65 /* Config.xcconfig */,
);
path = Configuration;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
7555FF7A242A565900829871 /* iosApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = (
F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */,
7555FF77242A565900829871 /* Sources */,
7555FF79242A565900829871 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = iosApp;
productName = iosApp;
productReference = 7555FF7B242A565900829871 /* .app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
7555FF73242A565900829871 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1130;
LastUpgradeCheck = 1130;
ORGANIZATIONNAME = orgName;
TargetAttributes = {
7555FF7A242A565900829871 = {
CreatedOnToolsVersion = 11.3.1;
};
};
};
buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 7555FF72242A565900829871;
productRefGroup = 7555FF7C242A565900829871 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
7555FF7A242A565900829871 /* iosApp */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
7555FF79242A565900829871 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */,
058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Compile Kotlin Framework";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
7555FF77242A565900829871 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
7555FF83242A565900829871 /* ContentView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
7555FFA3242A565B00829871 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
7555FFA4242A565B00829871 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
7555FFA6242A565B00829871 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
);
INFOPLIST_FILE = iosApp/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-framework",
composeApp,
);
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
PRODUCT_NAME = "${APP_NAME}";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
7555FFA7242A565B00829871 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
);
INFOPLIST_FILE = iosApp/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-framework",
composeApp,
);
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
PRODUCT_NAME = "${APP_NAME}";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7555FFA3242A565B00829871 /* Debug */,
7555FFA4242A565B00829871 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7555FFA6242A565B00829871 /* Debug */,
7555FFA7242A565B00829871 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 7555FF73242A565900829871 /* Project object */;
}

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "app-icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,21 @@
import UIKit
import SwiftUI
import ComposeApp
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea(.keyboard) // Compose has own keyboard handler
}
}

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,10 @@
import SwiftUI
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

File diff suppressed because it is too large Load Diff

@ -24,7 +24,10 @@ pluginManagement {
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
// https://youtrack.jetbrains.com/issue/KT-55620
// Issue: Could not determine the dependencies of task ':kotlinNodeJsSetup'.
// Fix target versions: Kotlin 2.0.0-Beta1
// repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()

Loading…
Cancel
Save