Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
88f43a7
chore: sync with repositories base
sannarat Apr 10, 2026
f23d828
fix: add dep
sannarat Apr 16, 2026
1266135
feat: add files to work
sannarat Apr 16, 2026
a8a7064
feat: add new dep for await()
sannarat Apr 16, 2026
2db5dbf
fix: fix name
sannarat Apr 16, 2026
800444d
feat: add userId field
N1smi Apr 16, 2026
593b4cc
Merge branch 'feature/implement-sync-foundation' of https://github.co…
N1smi Apr 16, 2026
1ed6f4a
fix: fix dep
sannarat Apr 16, 2026
378b183
feat: add data class AuthManager
sannarat Apr 16, 2026
e4676b0
feat: add new dep
sannarat Apr 16, 2026
95cad54
feat: add class SynkWorker
sannarat Apr 16, 2026
572a0ce
feat: implement FirebaseCloudDataSource class
N1smi Apr 16, 2026
e513901
Merge branch 'feature/implement-sync-foundation' of https://github.co…
N1smi Apr 16, 2026
cabcc8a
fix: fix build errors and detekt issues in cloud data source
N1smi Apr 17, 2026
2597c43
refactor: narrow lint suppression for gRPC in data and app modules
N1smi Apr 17, 2026
bc0c972
feat: implement NoteDto data class
N1smi Apr 17, 2026
4698304
feat: implement NoteEntityJsonConverter class
N1smi Apr 17, 2026
a6a7a7e
chore: for pull
sannarat Apr 17, 2026
f1cd09c
Fix conflict: removed lint-baseline.xml to match remote
sannarat Apr 17, 2026
7c556c8
feat: add class SyncManagerImpl
sannarat Apr 17, 2026
ec775b7
fix: fix build
sannarat Apr 17, 2026
1b50bbb
refactor: remove @Searizable annotations
N1smi Apr 17, 2026
a7f3a2d
feat: implement ContentItemDto sealed class
N1smi Apr 17, 2026
bd0999f
feat: implement ContentItemMapper
N1smi Apr 17, 2026
c993be2
fix: modify NoteMapper class to reflect the changes
N1smi Apr 17, 2026
3e6c646
Merge branch 'feature/implement-sync-foundation' of https://github.co…
N1smi Apr 17, 2026
275b4bc
fix: fix build
N1smi Apr 17, 2026
6db75fc
Fix conflict: removed lint-baseline.xml to match remote
sannarat Apr 17, 2026
71c906d
delete empty file
sannarat Apr 17, 2026
fd0f271
fix: fix build
sannarat Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions ai/gradle.lockfile

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
id("com.google.gms.google-services")
}

android {
Expand Down Expand Up @@ -57,6 +58,11 @@ android {
}
}
}

lint {
lintConfig = file("lint.xml")
abortOnError = true
}
}

kotlin {
Expand Down Expand Up @@ -86,4 +92,6 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
}
29 changes: 29 additions & 0 deletions app/google-services.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "267244224775",
"project_id": "openvino-notes",
"storage_bucket": "openvino-notes.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:267244224775:android:fb17f8492f57205ffec5a2",
"android_client_info": {
"package_name": "com.itlab.notes"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDLAUPgzEm2R49WU4iGidAUj5b1jBb6FjQ"

Check warning

Code scanning / Gitleaks

Uncovered a GCP API key, which could lead to unauthorized access to Google Cloud services and data breaches. Warning

gcp-api-key has detected secret for file app/google-services.json at commit 800444d13d99ef592c00ed406d81a20f9898d00c.
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
428 changes: 428 additions & 0 deletions app/gradle.lockfile

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions app/lint.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="InvalidPackage">
<ignore path="**/grpc-core-*.jar" />
</issue>
</lint>
4 changes: 2 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Notes">

<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Notes">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
</manifest>
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ plugins {
alias(libs.plugins.detekt)
alias(libs.plugins.kover)
kotlin("plugin.serialization") version "2.3.20" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
}

configure<KtlintExtension> {
Expand Down
13 changes: 13 additions & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

lint {
lintConfig = file("lint.xml")
abortOnError = true
}
}

kotlin {
Expand All @@ -40,16 +45,24 @@ kotlin {
}

dependencies {
implementation(platform(libs.firebase.bom))
implementation(project(":domain"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.firebase.firestore)
implementation(libs.firebase.storage)
implementation(libs.firebase.auth)
implementation(libs.firebase.ui.auth)
implementation(libs.androidx.work.runtime.ktx)
ksp(libs.room.compiler)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.timber)
implementation(libs.firebase.analytics)
testImplementation(libs.junit)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
Expand Down
398 changes: 398 additions & 0 deletions data/gradle.lockfile

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions data/lint.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="InvalidPackage">
<ignore path="**/grpc-core-*.jar" />
</issue>
</lint>
28 changes: 28 additions & 0 deletions data/src/main/java/com/itlab/data/cloud/AuthManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.itlab.data.cloud

import android.content.Context
import android.content.Intent
import com.firebase.ui.auth.AuthUI
import com.google.firebase.auth.FirebaseAuth

class AuthManager(
private val auth: FirebaseAuth,
) {
fun getSignInIntent(): Intent =
AuthUI
.getInstance()
.createSignInIntentBuilder()
.setAvailableProviders(
listOf(
AuthUI.IdpConfig.EmailBuilder().build(),
AuthUI.IdpConfig.GoogleBuilder().build(),
),
).setIsSmartLockEnabled(false)
.build()

fun getCurrentUserId(): String? = auth.currentUser?.uid

fun signOut(context: Context) {
AuthUI.getInstance().signOut(context)
}
}
101 changes: 101 additions & 0 deletions data/src/main/java/com/itlab/data/cloud/FirebaseCloudDataSource.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.itlab.data.cloud

import com.google.firebase.storage.FirebaseStorage
import com.itlab.domain.cloud.CloudDataSource
import com.itlab.domain.cloud.CloudNoteMetadata
import com.itlab.domain.cloud.Result
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.tasks.await
import kotlinx.datetime.Instant
import java.io.File

class FirebaseCloudDataSource(
private val storage: FirebaseStorage = FirebaseStorage.getInstance(),
) : CloudDataSource {
private val rootRef = storage.reference

override suspend fun listNoteMetadata(userId: String): Result<List<CloudNoteMetadata>> =
safeCall {
val listRef = rootRef.child("users/$userId/notes")
val result = listRef.listAll().await()

val metadataList =
result.items.map { itemRef ->
val metadata = itemRef.metadata.await()
CloudNoteMetadata(
key = itemRef.path,
updatedAt = Instant.fromEpochMilliseconds(metadata.updatedTimeMillis),
)
}
metadataList
}

override suspend fun downloadNote(key: String): Result<String> =
safeCall {
val fileRef = rootRef.child(key)
val bytes = fileRef.getBytes(MAX_NOTE_SIZE).await()
String(bytes)
}

override suspend fun uploadNote(
key: String,
json: String,
): Result<Unit> =
safeCall {
val fileRef = rootRef.child(key)
fileRef.putBytes(json.toByteArray()).await()
Unit
}

override suspend fun deleteNote(key: String): Result<Unit> =
safeCall {
rootRef.child(key).delete().await()
Unit
}

override suspend fun uploadMedia(
key: String,
file: File,
): Result<Unit> =
safeCall {
val fileRef = rootRef.child(key)
file.inputStream().use { stream ->
fileRef.putStream(stream).await()
}
Unit
}

override suspend fun downloadMedia(
key: String,
destination: File,
): Result<Unit> =
safeCall {
val fileRef = rootRef.child(key)
fileRef.getFile(destination).await()
Unit
}

override suspend fun deleteMedia(key: String): Result<Unit> =
safeCall {
rootRef.child(key).delete().await()
Unit
}

@Suppress("TooGenericExceptionCaught")
private suspend inline fun <T> safeCall(crossinline block: suspend () -> T): Result<T> =
try {
Result.Success(block())
} catch (e: CancellationException) {
throw e
} catch (e: com.google.firebase.FirebaseException) {
Result.Error(e)
} catch (e: java.io.IOException) {
Result.Error(e)
} catch (e: Exception) {
Result.Error(e)
}

companion object {
private const val MAX_NOTE_SIZE = 5 * 1024 * 1024L // 5MB
}
}
115 changes: 115 additions & 0 deletions data/src/main/java/com/itlab/data/cloud/SyncManagerImpl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.itlab.data.cloud

import com.itlab.data.dao.NoteDao
import com.itlab.data.mapper.NoteEntityJsonConverter
import com.itlab.domain.cloud.CloudDataSource
import com.itlab.domain.cloud.Result
import com.itlab.domain.cloud.SyncManager
import com.itlab.domain.cloud.SyncState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.SerializationException
import timber.log.Timber
import java.io.IOException

class SyncManagerImpl(
private val noteDao: NoteDao,
private val cloudDataSource: CloudDataSource,
private val jsonConverter: NoteEntityJsonConverter,
) : SyncManager {
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
override val syncState: StateFlow<SyncState> = _syncState.asStateFlow()

override suspend fun sync(userId: String) {
_syncState.value = SyncState.Syncing

try {
pushChanges(userId)
pullUpdates(userId)

_syncState.value = SyncState.Success
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
handleError("Network error during sync", e)
throw e
} catch (e: SerializationException) {
handleError("Data parsing error", e)
throw e
} catch (e: IllegalStateException) {
handleError("Invalid state during sync", e)
throw e
}
}

private fun handleError(
message: String,
e: Exception,
) {
Timber.e(e, message)
_syncState.value = SyncState.Error(e.message ?: "Unknown error")
}

override suspend fun pushChanges(userId: String) {
val unsyncedEntities = noteDao.getUnsyncedNotes()

for (entity in unsyncedEntities) {
val json = with(jsonConverter) { entity.toJson() }

val result = cloudDataSource.uploadNote(entity.id, json)

when (result) {
is Result.Success -> {
val syncedEntity = entity.copy(isSynced = true)
noteDao.update(syncedEntity)
}
is Result.Error -> {
Timber.e(result.exception, "Couldn't upload the note ${entity.id}")
throw result.exception
}
}
}
}

override suspend fun pullUpdates(userId: String) {
val metadataResult = cloudDataSource.listNoteMetadata(userId)

val remoteMetadata =
when (metadataResult) {
is Result.Success -> metadataResult.data
is Result.Error -> throw metadataResult.exception
}

val localNotes = noteDao.getAllNotes().first()
val localIds = localNotes.map { it.id }

val toDownload =
remoteMetadata.filter { cloudMeta ->
cloudMeta.key !in localIds
}

for (meta in toDownload) {
val downloadResult = cloudDataSource.downloadNote(meta.key)

when (downloadResult) {
is Result.Success -> {
val entity =
jsonConverter.toEntity(
jsonString = downloadResult.data,
userId = userId,
)
noteDao.insert(entity)
}
is Result.Error -> {
Timber.e(downloadResult.exception, "Couldn't download the note ${meta.key}")
throw downloadResult.exception
}
}
}
}
}
Loading
Loading