Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bc4f187
feat: add Google Drive support
SmilingPixel Jan 17, 2026
a68664d
feat: add Google OAuth client dependency and implement credential ret…
SmilingPixel Jan 18, 2026
2061490
feat: add Google Drive Login in settings
SmilingPixel Jan 19, 2026
861c4ea
feat: integrate Google Drive Auth for Android
SmilingPixel Jan 20, 2026
19ab7a7
feat: update package name for Android integration
SmilingPixel Jan 21, 2026
16d603a
refactor: fix indentation and clean imports
SmilingPixel Jan 23, 2026
0bff97a
feat: implement mutex for concurrent sign-in requests and update cred…
SmilingPixel Jan 24, 2026
2d5c9e6
doc: document about Android applicationId
SmilingPixel Jan 25, 2026
d3e207b
feat: replace printStackTrace with Logger for error handling
SmilingPixel Jan 25, 2026
cc0f444
refactor: code clean
SmilingPixel Jan 25, 2026
1154524
doc: CloudDriveClient interface methods
SmilingPixel Jan 26, 2026
558ff32
feat: enhance GoogleSignInHelper with logging and improved authDeferr…
SmilingPixel Jan 27, 2026
b9a9eed
feat: enhance SettingsScreen with loading state and error handling
SmilingPixel Jan 28, 2026
09bba31
feat: improve GoogleDriveClient with context initialization check and…
SmilingPixel Jan 29, 2026
dee28c0
fix: check if the launcher is already registered
SmilingPixel Jan 31, 2026
c4b4515
feat: refactor to use MIME_TYPE_FOLDER from common
SmilingPixel Jan 30, 2026
068832f
fix: sanitize folderId to prevent injection
SmilingPixel Feb 1, 2026
7e4fa9f
Merge remote-tracking branch 'origin/main' into feat/cloud_drive_0113
SmilingPixel Feb 2, 2026
b20751f
fix: compile error
SmilingPixel Feb 2, 2026
ee0cf29
fix: remove malformed code
SmilingPixel Feb 3, 2026
d9076ce
fix: resource leak
SmilingPixel Feb 3, 2026
f8eeea5
fix: check if task is completed
SmilingPixel Feb 3, 2026
d79b504
fix: service cache inconsistency
SmilingPixel Feb 3, 2026
8f77cbd
fix: race condition
SmilingPixel Feb 3, 2026
068ca1e
fix(android): make GoogleSignIn launcher lifecycle-aware
SmilingPixel Feb 4, 2026
c099054
fix(SettingsScreen): ensure LifecycleEventEffect uses latest scope
SmilingPixel Feb 5, 2026
791f657
fix: initialization order for main activity and code clean
SmilingPixel Feb 6, 2026
6c7a840
fix: race condition
SmilingPixel Feb 7, 2026
8f9a2e0
fix(settings): use safe scoping for nullable errorMessage in Settings…
SmilingPixel Feb 8, 2026
48284d7
fix(GoogleDriveClient): use dynamic port assignment for LocalServerRe…
SmilingPixel Feb 9, 2026
1467aa5
fix(SettingsScreen): handle CancellationException
SmilingPixel Feb 10, 2026
f46714e
refactor: code and imports clean
SmilingPixel Feb 11, 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
12 changes: 9 additions & 3 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ kotlin {

androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.play.services.auth)
implementation(libs.google.api.client.android)
implementation(libs.google.api.services.drive)
}
androidMain.get().dependsOn(nonWebMain)

Expand All @@ -103,6 +106,9 @@ kotlin {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.ktor.client.java)
implementation(libs.google.api.client)
implementation(libs.google.api.services.drive)
implementation(libs.google.oauth.client.jetty)
}
jvmMain.get().dependsOn(nonWebMain)

Expand All @@ -117,11 +123,11 @@ room {
}

android {
namespace = "io.github.smiling_pixel"
namespace = "io.github.smiling_pixel.mark_day"
compileSdk = libs.versions.android.compileSdk.get().toInt()

defaultConfig {
applicationId = "io.github.smiling_pixel"
applicationId = "io.github.smiling_pixel.mark_day"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
Expand Down Expand Up @@ -157,7 +163,7 @@ compose.desktop {

nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "io.github.smiling_pixel"
packageName = "io.github.smiling_pixel.mark_day"
packageVersion = "1.0.0"
}
}
Expand Down
7 changes: 6 additions & 1 deletion composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<!-- Note:
The Android namespace/applicationId is configured separately in build.gradle.kts
(e.g., io.github.smiling_pixel.mark_day), while MainActivity remains in the
Kotlin package io.github.smiling_pixel. We use the fully qualified class name
here intentionally to reference the correct activity implementation. -->
<activity
android:exported="true"
android:name=".MainActivity">
android:name="io.github.smiling_pixel.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,28 @@ import io.github.smiling_pixel.database.DiaryRepository
import io.github.smiling_pixel.filesystem.FileRepository
import io.github.smiling_pixel.filesystem.fileManager
import io.github.smiling_pixel.preference.AndroidContextProvider
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch

import androidx.activity.result.contract.ActivityResultContracts
import io.github.smiling_pixel.client.GoogleSignInHelper

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
enableEdgeToEdge()

AndroidContextProvider.context = this.applicationContext

GoogleSignInHelper.registerLauncher(
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
lifecycleScope.launch {
GoogleSignInHelper.onActivityResult(result)
}
}
)

// Build Room-backed repository on Android and pass it into App
val db = createDatabase(this)
val repo = DiaryRepository(db.diaryDao())
Expand All @@ -27,6 +42,11 @@ class MainActivity : ComponentActivity() {
App(repo, fileRepo)
}
}

override fun onDestroy() {
super.onDestroy()
GoogleSignInHelper.unregisterLauncher()
}
Comment on lines +45 to +49
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unregistering the launcher in onDestroy could cause issues if a sign-in flow is in progress when the activity is destroyed. The pending CompletableDeferred in GoogleSignInHelper would never complete, potentially causing the coroutine to hang indefinitely. Consider cancelling any pending authorization attempts in GoogleSignInHelper before unregistering, or handle this case gracefully by completing the deferred with a cancellation result.

Suggested change
override fun onDestroy() {
super.onDestroy()
GoogleSignInHelper.unregisterLauncher()
}

Copilot uses AI. Check for mistakes.
}

@Preview
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
package io.github.smiling_pixel.client

import android.content.Context
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.Scope
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.extensions.android.http.AndroidHttp
import com.google.api.client.http.ByteArrayContent
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
import com.google.api.services.drive.model.File
import io.github.smiling_pixel.util.Logger
import io.github.smiling_pixel.util.e
import io.github.smiling_pixel.preference.AndroidContextProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.ByteArrayOutputStream
import java.util.Collections

class GoogleDriveClient : CloudDriveClient {

/**
* Retrieves the Android Application Context.
*
* This property accesses [AndroidContextProvider.context]. Ensure that [AndroidContextProvider.context]
* is initialized (typically in `MainActivity.onCreate`) before any methods of this client are called.
*
* @throws IllegalStateException if [AndroidContextProvider.context] has not been initialized yet.
*/
private val context: Context
get() = try {
AndroidContextProvider.context
} catch (e: UninitializedPropertyAccessException) {
throw IllegalStateException(
"AndroidContextProvider.context is not initialized. " +
"Ensure it is set before using GoogleDriveClient.",
e
)
}

private val jsonFactory = GsonFactory.getDefaultInstance()
private val appName = "MarkDay Diary"

private val serviceMutex = Mutex()
@Volatile
private var driveService: Drive? = null

private fun getService(): Drive {
return driveService ?: throw IllegalStateException("Google Drive not authorized")
}

// Checking auth state and initializing service if possible
private suspend fun checkAndInitService(): Boolean {
if (driveService != null) return true

return serviceMutex.withLock {
if (driveService != null) return@withLock true

val account = GoogleSignIn.getLastSignedInAccount(context)
val driveScope = Scope(DriveScopes.DRIVE_FILE)

if (account != null && GoogleSignIn.hasPermissions(account, driveScope)) {
val email = account.email
if (email != null) {
initService(email)
return@withLock true
}
}
false
}
}

private fun initService(email: String) {
val credential = GoogleAccountCredential.usingOAuth2(
context, Collections.singleton(DriveScopes.DRIVE_FILE)
)
credential.selectedAccountName = email

driveService = Drive.Builder(
AndroidHttp.newCompatibleTransport(),
jsonFactory,
credential
).setApplicationName(appName).build()
}

override suspend fun isAuthorized(): Boolean = withContext(Dispatchers.IO) {
checkAndInitService()
}

override suspend fun authorize(): Boolean {
// First, try to initialize the service on IO without switching to Main unnecessarily
if (withContext(Dispatchers.IO) { checkAndInitService() }) return true
// If not yet authorized, perform the sign-in flow on the main thread
return withContext(Dispatchers.Main) {
val driveScope = Scope(DriveScopes.DRIVE_FILE)
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(driveScope)
.build()
val client = GoogleSignIn.getClient(context, gso)
val signInIntent = client.signInIntent
val result = GoogleSignInHelper.launchSignIn(signInIntent)
if (result != null && result.resultCode == android.app.Activity.RESULT_OK) {
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try {
val account = task.getResult(ApiException::class.java)
if (account != null) {
val email = account.email
if (email != null) {
serviceMutex.withLock {
initService(email)
}
return@withContext true
}
}
} catch (e: Exception) {
Logger.e("GoogleDriveClient", "Authorization failed: ${e.message}")
e.printStackTrace()
}
}
false
}
}

override suspend fun signOut() = withContext(Dispatchers.Main) {
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()
val client = GoogleSignIn.getClient(context, gso)

val deferred = kotlinx.coroutines.CompletableDeferred<Unit>()
client.signOut().addOnCompleteListener { task ->
if (task.isSuccessful) {
driveService = null
deferred.complete(Unit)
} else {
val e = task.exception ?: RuntimeException("Sign out failed")
Logger.e("GoogleDriveClient", "Sign out failed: ${e.message}")
deferred.completeExceptionally(e)
}
}
deferred.await()
}

override suspend fun getUserInfo(): UserInfo? = withContext(Dispatchers.IO) {
if (!checkAndInitService()) return@withContext null
val account = GoogleSignIn.getLastSignedInAccount(context) ?: return@withContext null
val photoUrl = account.photoUrl?.toString()
UserInfo(
name = account.displayName ?: "",
email = account.email ?: "",
photoUrl = photoUrl
)
}

override suspend fun listFiles(parentId: String?): List<DriveFile> = withContext(Dispatchers.IO) {
val folderId = parentId ?: "root"
// Sanitize folderId to prevent injection
val sanitizedFolderId = folderId.replace("'", "\\'")
Comment on lines +161 to +162
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sanitization approach using replace("'", "\\'") is insufficient for preventing query injection in Google Drive queries. While it handles single quotes, it doesn't account for backslashes in the input. An attacker could provide a parentId like root\' or '1'='1 which would become root\\' or '1'='1 after sanitization, still allowing injection. Additionally, other special characters like backslash should be escaped. Consider using a more robust escaping function or validating that parentId matches expected patterns (e.g., alphanumeric with specific allowed characters).

Suggested change
// Sanitize folderId to prevent injection
val sanitizedFolderId = folderId.replace("'", "\\'")
// Sanitize folderId to prevent injection by escaping backslashes and single quotes
val sanitizedFolderId = folderId
.replace("\\", "\\\\")
.replace("'", "\\'")

Copilot uses AI. Check for mistakes.
val query = "'$sanitizedFolderId' in parents and trashed = false"
Comment on lines +161 to +163
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sanitization approach using replace("'", "\\'") is insufficient and potentially incorrect for Google Drive API queries. The backslash itself may need escaping depending on the context. Additionally, this doesn't protect against other special characters. The Google Drive API query language has its own escaping rules. Consider using proper query building methods or ensuring the escaping matches Google's documented requirements.

Copilot uses AI. Check for mistakes.

val result = getService().files().list()
.setQ(query)
.setFields("nextPageToken, files(id, name, mimeType)")
.execute()

result.files?.map { file ->
DriveFile(
id = file.id,
name = file.name,
mimeType = file.mimeType,
isFolder = file.mimeType == CloudDriveClient.MIME_TYPE_FOLDER
)
} ?: emptyList()
}

override suspend fun createFile(name: String, content: ByteArray, mimeType: String, parentId: String?): DriveFile = withContext(Dispatchers.IO) {
val fileMetadata = File().apply {
this.name = name
this.mimeType = mimeType
if (parentId != null) {
this.parents = listOf(parentId)
}
}

val mediaContent = ByteArrayContent(mimeType, content)

val file = getService().files().create(fileMetadata, mediaContent)
.setFields("id, name, mimeType, parents")
.execute()

DriveFile(
id = file.id,
name = file.name,
mimeType = file.mimeType,
isFolder = file.mimeType == CloudDriveClient.MIME_TYPE_FOLDER
)
}

override suspend fun createFolder(name: String, parentId: String?): DriveFile = withContext(Dispatchers.IO) {
val fileMetadata = File().apply {
this.name = name
this.mimeType = CloudDriveClient.MIME_TYPE_FOLDER
if (parentId != null) {
this.parents = listOf(parentId)
}
}

val file = getService().files().create(fileMetadata)
.setFields("id, name, mimeType")
.execute()

DriveFile(
id = file.id,
name = file.name,
mimeType = CloudDriveClient.MIME_TYPE_FOLDER,
isFolder = true
)
}

override suspend fun deleteFile(fileId: String): Unit = withContext(Dispatchers.IO) {
getService().files().delete(fileId).execute()
}

override suspend fun downloadFile(fileId: String): ByteArray = withContext(Dispatchers.IO) {
val outputStream = ByteArrayOutputStream()
getService().files().get(fileId)
.executeMediaAndDownloadTo(outputStream)
outputStream.toByteArray()
}

override suspend fun updateFile(fileId: String, content: ByteArray): DriveFile = withContext(Dispatchers.IO) {
val fileMetadata = File()
val existingFile = getService().files().get(fileId).setFields("mimeType").execute()
val mimeType = existingFile.mimeType

val mediaContent = ByteArrayContent(mimeType, content)

val updatedFile = getService().files().update(fileId, fileMetadata, mediaContent)
.setFields("id, name, mimeType")
.execute()

Comment on lines +236 to +245
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updateFile method makes two sequential API calls - first to retrieve the existing mimeType, then to update the file. This doubles the network overhead and latency. Consider one of these alternatives: (1) pass mimeType as a parameter to updateFile, (2) use a generic mime type like "application/octet-stream" without fetching, or (3) add an optional parameter to specify whether to preserve the existing mime type. The current approach impacts performance unnecessarily.

Suggested change
val fileMetadata = File()
val existingFile = getService().files().get(fileId).setFields("mimeType").execute()
val mimeType = existingFile.mimeType
val mediaContent = ByteArrayContent(mimeType, content)
val updatedFile = getService().files().update(fileId, fileMetadata, mediaContent)
.setFields("id, name, mimeType")
.execute()
val fileMetadata = File()
val mediaContent = ByteArrayContent("application/octet-stream", content)
val updatedFile = getService().files().update(fileId, fileMetadata, mediaContent)
.setFields("id, name, mimeType")
.execute()

Copilot uses AI. Check for mistakes.
DriveFile(
id = updatedFile.id,
name = updatedFile.name,
mimeType = updatedFile.mimeType,
isFolder = updatedFile.mimeType == CloudDriveClient.MIME_TYPE_FOLDER
)
}
}

private val googleDriveClientInstance by lazy { GoogleDriveClient() }
actual fun getCloudDriveClient(): CloudDriveClient = googleDriveClientInstance
Loading
Loading