From bc4f1876d0207a21c76db1776e8866a93fcc2c7c Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Sat, 17 Jan 2026 02:08:38 +0000 Subject: [PATCH 01/14] feat: add Google Drive support --- composeApp/build.gradle.kts | 2 + .../smiling_pixel/client/CloudDriveClient.kt | 62 +++++++ .../smiling_pixel/client/GoogleDriveClient.kt | 152 ++++++++++++++++++ .../smiling_pixel/client/GoogleDriveClient.kt | 32 ++++ gradle/libs.versions.toml | 3 + 5 files changed, 251 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt create mode 100644 composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt create mode 100644 composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index af264df..4a0a7d5 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -103,6 +103,8 @@ 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) } jvmMain.get().dependsOn(nonWebMain) diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt new file mode 100644 index 0000000..8784fa8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt @@ -0,0 +1,62 @@ +package io.github.smiling_pixel.client + +/** + * Represents a file or folder in the cloud drive. + */ +data class DriveFile( + val id: String, + val name: String, + val mimeType: String, + val isFolder: Boolean +) + +/** + * Client interface for accessing and managing files on cloud drives. + */ +interface CloudDriveClient { + /** + * Lists files and folders in the specified parent folder. + * @param parentId The ID of the parent folder. If null, lists files in the root. + * @return List of [DriveFile]s. + */ + suspend fun listFiles(parentId: String? = null): List + + /** + * Creates a new file. + * @param name The name of the file. + * @param content The content of the file. + * @param mimeType The MIME type of the file. + * @param parentId The ID of the parent folder. If null, creates in the root. + * @return The created [DriveFile]. + */ + suspend fun createFile(name: String, content: ByteArray, mimeType: String, parentId: String? = null): DriveFile + + /** + * Creates a new folder. + * @param name The name of the folder. + * @param parentId The ID of the parent folder. If null, creates in the root. + * @return The created folder as a [DriveFile]. + */ + suspend fun createFolder(name: String, parentId: String? = null): DriveFile + + /** + * Deletes a file or folder. + * @param fileId The ID of the file or folder to delete. + */ + suspend fun deleteFile(fileId: String) + + /** + * Downloads the content of a file. + * @param fileId The ID of the file to download. + * @return The content of the file as [ByteArray]. + */ + suspend fun downloadFile(fileId: String): ByteArray + + /** + * Updates the content of an existing file. + * @param fileId The ID of the file to update. + * @param content The new content of the file. + * @return The updated [DriveFile]. + */ + suspend fun updateFile(fileId: String, content: ByteArray): DriveFile +} diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt new file mode 100644 index 0000000..2a265d2 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -0,0 +1,152 @@ +package io.github.smiling_pixel.client + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +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.model.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +/** + * Implementation of [CloudDriveClient] for Google Drive on JVM. + * Uses the official Google Drive Java client library. + * + * References: + * https://developers.google.com/workspace/drive/api/quickstart/java + * https://developers.google.com/workspace/drive/api/guides/search-files + * https://developers.google.com/workspace/drive/api/guides/manage-files + */ +class GoogleDriveClient : CloudDriveClient { + + private val jsonFactory = GsonFactory.getDefaultInstance() + private val applicationName = "MarkDay Diary" + + // TODO: Initialize Drive service with proper authentication. + // The authentication process requires a valid Credential object. + // See: https://developers.google.com/workspace/drive/api/modules/auth + private val driveService: Drive by lazy { + // TODO: Handle GeneralSecurityException and IOException properly in a real app or during init + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + + // TODO: Retrieve the actual credential (e.g., from an authorized user session). + // This is a placeholder. You need to implement the OAuth2 flow or pass a valid Credential. + /* + val credential = GoogleCredential.Builder() + .setTransport(httpTransport) + .setJsonFactory(jsonFactory) + .setClientSecrets(...) + .build() + */ + val credential = null // Placeholder for compilation (will throw NPE at runtime if used) + + Drive.Builder(httpTransport, jsonFactory, credential) + .setApplicationName(applicationName) + .build() + } + + override suspend fun listFiles(parentId: String?): List = withContext(Dispatchers.IO) { + val folderId = parentId ?: "root" + val query = "'$folderId' in parents and trashed = false" + + val result = driveService.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 == 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 = driveService.files().create(fileMetadata, mediaContent) + .setFields("id, name, mimeType, parents") + .execute() + + DriveFile( + id = file.id, + name = file.name, + mimeType = file.mimeType, + isFolder = file.mimeType == MIME_TYPE_FOLDER + ) + } + + override suspend fun createFolder(name: String, parentId: String?): DriveFile = withContext(Dispatchers.IO) { + val fileMetadata = File().apply { + this.name = name + this.mimeType = MIME_TYPE_FOLDER + if (parentId != null) { + this.parents = listOf(parentId) + } + } + + val file = driveService.files().create(fileMetadata) + .setFields("id, name, mimeType") + .execute() + + DriveFile( + id = file.id, + name = file.name, + mimeType = MIME_TYPE_FOLDER, + isFolder = true + ) + } + + override suspend fun deleteFile(fileId: String): Unit = withContext(Dispatchers.IO) { + driveService.files().delete(fileId).execute() + } + + override suspend fun downloadFile(fileId: String): ByteArray = withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + driveService.files().get(fileId) + .executeMediaAndDownloadTo(outputStream) + outputStream.toByteArray() + } + + override suspend fun updateFile(fileId: String, content: ByteArray): DriveFile = withContext(Dispatchers.IO) { + // Retrieve current metadata to keep name/mimeType if needed, or just update content + // Creating a new File object with empty metadata to only update content is possible, + // but often we might want to update modified time etc. + val fileMetadata = File() + + // We need to guess the mime type or retrieve it. For update, let's assume we keep existing or use generic. + // But ByteArrayContent needs a type. + // Let's fetch the file first to get the mimeType. + val existingFile = driveService.files().get(fileId).setFields("mimeType").execute() + val mimeType = existingFile.mimeType + + val mediaContent = ByteArrayContent(mimeType, content) + + val updatedFile = driveService.files().update(fileId, fileMetadata, mediaContent) + .setFields("id, name, mimeType") + .execute() + + DriveFile( + id = updatedFile.id, + name = updatedFile.name, + mimeType = updatedFile.mimeType, + isFolder = updatedFile.mimeType == MIME_TYPE_FOLDER + ) + } + + companion object { + private const val MIME_TYPE_FOLDER = "application/vnd.google-apps.folder" + } +} diff --git a/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt new file mode 100644 index 0000000..3ea2159 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -0,0 +1,32 @@ +package io.github.smiling_pixel.client + +/** + * Implementation of [CloudDriveClient] for Google Drive on Web. + * CURRENTLY NOT IMPLEMENTED. + */ +class GoogleDriveClient : CloudDriveClient { + + override suspend fun listFiles(parentId: String?): List { + throw NotImplementedError("Google Drive is not supported on Web target yet.") + } + + override suspend fun createFile(name: String, content: ByteArray, mimeType: String, parentId: String?): DriveFile { + throw NotImplementedError("Google Drive is not supported on Web target yet.") + } + + override suspend fun createFolder(name: String, parentId: String?): DriveFile { + throw NotImplementedError("Google Drive is not supported on Web target yet.") + } + + override suspend fun deleteFile(fileId: String) { + throw NotImplementedError("Google Drive is not supported on Web target yet.") + } + + override suspend fun downloadFile(fileId: String): ByteArray { + throw NotImplementedError("Google Drive is not supported on Web target yet.") + } + + override suspend fun updateFile(fileId: String, content: ByteArray): DriveFile { + throw NotImplementedError("Google Drive is not supported on Web target yet.") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1fee7ba..2a6de07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,9 @@ multiplatform-markdown-renderer-coil3 = { module = "com.mikepenz:multiplatform-m coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +google-api-client = { module = "com.google.api-client:google-api-client", version = "2.7.0" } +google-api-services-drive = { module = "com.google.apis:google-api-services-drive", version = "v3-rev20241027-2.0.0" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } From a68664d012633d4784b110dfefb1055340a3ad20 Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Sun, 18 Jan 2026 02:18:26 +0000 Subject: [PATCH 02/14] feat: add Google OAuth client dependency and implement credential retrieval for Google Drive --- composeApp/build.gradle.kts | 1 + .../smiling_pixel/client/GoogleDriveClient.kt | 61 ++++++++++++++----- gradle/libs.versions.toml | 1 + 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 4a0a7d5..dd0eb97 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -105,6 +105,7 @@ kotlin { 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) diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 2a265d2..956d86d 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -1,13 +1,25 @@ package io.github.smiling_pixel.client +import com.google.api.client.auth.oauth2.Credential +import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp +import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow +import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.http.ByteArrayContent +import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory +import com.google.api.client.util.store.FileDataStoreFactory import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes import com.google.api.services.drive.model.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.InputStreamReader +import java.util.Collections +import java.io.File as JavaFile /** * Implementation of [CloudDriveClient] for Google Drive on JVM. @@ -22,24 +34,43 @@ class GoogleDriveClient : CloudDriveClient { private val jsonFactory = GsonFactory.getDefaultInstance() private val applicationName = "MarkDay Diary" + + /** + * Directory to store authorization tokens for this application. + */ + private val TOKENS_DIRECTORY_PATH = "tokens" + + /** + * Global instance of the scopes required by this quickstart. + * If modifying these scopes, delete your previously saved tokens/ folder. + */ + private val SCOPES = listOf(DriveScopes.DRIVE_FILE) + private val CREDENTIALS_FILE_PATH = "/credentials.json" + + private fun getCredentials(httpTransport: NetHttpTransport): Credential { + // TODO: https://developers.google.com/workspace/drive/api/quickstart/java + // Load client secrets. + val inputStream = GoogleDriveClient::class.java.getResourceAsStream(CREDENTIALS_FILE_PATH) + ?: throw FileNotFoundException("Resource not found: $CREDENTIALS_FILE_PATH. Please obtain credentials.json from Google Cloud Console.") + + val clientSecrets = GoogleClientSecrets.load(jsonFactory, InputStreamReader(inputStream)) + + // Build flow and trigger user authorization request. + val flow = GoogleAuthorizationCodeFlow.Builder( + httpTransport, jsonFactory, clientSecrets, SCOPES + ) + .setDataStoreFactory(FileDataStoreFactory(JavaFile(TOKENS_DIRECTORY_PATH))) + .setAccessType("offline") + .build() + + val receiver = LocalServerReceiver.Builder().setPort(8888).build() + // authorize("user") authorizes for the "user" user ID. + return AuthorizationCodeInstalledApp(flow, receiver).authorize("user") + } - // TODO: Initialize Drive service with proper authentication. - // The authentication process requires a valid Credential object. - // See: https://developers.google.com/workspace/drive/api/modules/auth private val driveService: Drive by lazy { - // TODO: Handle GeneralSecurityException and IOException properly in a real app or during init val httpTransport = GoogleNetHttpTransport.newTrustedTransport() - - // TODO: Retrieve the actual credential (e.g., from an authorized user session). - // This is a placeholder. You need to implement the OAuth2 flow or pass a valid Credential. - /* - val credential = GoogleCredential.Builder() - .setTransport(httpTransport) - .setJsonFactory(jsonFactory) - .setClientSecrets(...) - .build() - */ - val credential = null // Placeholder for compilation (will throw NPE at runtime if used) + val credential = getCredentials(httpTransport) Drive.Builder(httpTransport, jsonFactory, credential) .setApplicationName(applicationName) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a6de07..6d68515 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,7 @@ coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.re google-api-client = { module = "com.google.api-client:google-api-client", version = "2.7.0" } google-api-services-drive = { module = "com.google.apis:google-api-services-drive", version = "v3-rev20241027-2.0.0" } +google-oauth-client-jetty = { module = "com.google.oauth-client:google-oauth-client-jetty", version = "1.36.0" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From 20614901ffb03a69ff10c3749c27ae7f47b00d0c Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Mon, 19 Jan 2026 03:33:39 +0000 Subject: [PATCH 03/14] feat: add Google Drive Login in settings --- .../smiling_pixel/client/GoogleDriveClient.kt | 45 ++++++++ .../io/github/smiling_pixel/SettingsScreen.kt | 52 +++++++++ .../smiling_pixel/client/CloudDriveClient.kt | 14 +++ .../smiling_pixel/client/GoogleDriveClient.kt | 106 ++++++++++++++---- .../smiling_pixel/client/GoogleDriveClient.kt | 18 +++ 5 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt new file mode 100644 index 0000000..757442d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -0,0 +1,45 @@ +package io.github.smiling_pixel.client + +class GoogleDriveClient : CloudDriveClient { + override suspend fun listFiles(parentId: String?): List { + TODO("Not yet implemented") + } + + override suspend fun createFile(name: String, content: ByteArray, mimeType: String, parentId: String?): DriveFile { + TODO("Not yet implemented") + } + + override suspend fun createFolder(name: String, parentId: String?): DriveFile { + TODO("Not yet implemented") + } + + override suspend fun deleteFile(fileId: String) { + TODO("Not yet implemented") + } + + override suspend fun downloadFile(fileId: String): ByteArray { + TODO("Not yet implemented") + } + + override suspend fun updateFile(fileId: String, content: ByteArray): DriveFile { + TODO("Not yet implemented") + } + + override suspend fun isAuthorized(): Boolean { + return false + } + + override suspend fun authorize(): Boolean { + TODO("Not yet implemented") + } + + override suspend fun signOut() { + TODO("Not yet implemented") + } + + override suspend fun getUserInfo(): UserInfo? { + return null + } +} + +actual fun getCloudDriveClient(): CloudDriveClient = GoogleDriveClient() diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt index 011c69e..c6a5393 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt @@ -14,10 +14,17 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope +import io.github.smiling_pixel.client.UserInfo +import io.github.smiling_pixel.client.getCloudDriveClient import io.github.smiling_pixel.preference.getSettingsRepository import kotlinx.coroutines.launch @@ -28,6 +35,17 @@ fun SettingsScreen() { val apiKey by settingsRepository.googleWeatherApiKey.collectAsState(initial = null) val uriHandler = LocalUriHandler.current + val cloudDriveClient = remember { getCloudDriveClient() } + var userInfo by remember { mutableStateOf(null) } + var isAuthorized by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + isAuthorized = cloudDriveClient.isAuthorized() + if (isAuthorized) { + userInfo = cloudDriveClient.getUserInfo() + } + } + Column( modifier = Modifier .fillMaxSize() @@ -56,6 +74,40 @@ fun SettingsScreen() { singleLine = true ) + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Cloud Drive Sync", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + + if (isAuthorized) { + Text("Signed in as: ${userInfo?.name ?: "Loading..."}") + Text("Email: ${userInfo?.email ?: ""}") + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + scope.launch { + cloudDriveClient.signOut() + isAuthorized = false + userInfo = null + } + }) { + Text("Revoke Authorization") + } + } else { + Button(onClick = { + scope.launch { + if (cloudDriveClient.authorize()) { + isAuthorized = true + userInfo = cloudDriveClient.getUserInfo() + } + } + }) { + Text("Authorize Google Drive") + } + } + Spacer(modifier = Modifier.height(24.dp)) Text( diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt index 8784fa8..2b7f7d5 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt @@ -10,6 +10,12 @@ data class DriveFile( val isFolder: Boolean ) +data class UserInfo( + val name: String, + val email: String, + val photoUrl: String? = null +) + /** * Client interface for accessing and managing files on cloud drives. */ @@ -59,4 +65,12 @@ interface CloudDriveClient { * @return The updated [DriveFile]. */ suspend fun updateFile(fileId: String, content: ByteArray): DriveFile + + suspend fun isAuthorized(): Boolean + suspend fun authorize(): Boolean + suspend fun signOut() + suspend fun getUserInfo(): UserInfo? } + +expect fun getCloudDriveClient(): CloudDriveClient + diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 956d86d..b43fde5 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -47,41 +47,45 @@ class GoogleDriveClient : CloudDriveClient { private val SCOPES = listOf(DriveScopes.DRIVE_FILE) private val CREDENTIALS_FILE_PATH = "/credentials.json" - private fun getCredentials(httpTransport: NetHttpTransport): Credential { - // TODO: https://developers.google.com/workspace/drive/api/quickstart/java - // Load client secrets. + private fun getFlow(httpTransport: NetHttpTransport): GoogleAuthorizationCodeFlow { val inputStream = GoogleDriveClient::class.java.getResourceAsStream(CREDENTIALS_FILE_PATH) ?: throw FileNotFoundException("Resource not found: $CREDENTIALS_FILE_PATH. Please obtain credentials.json from Google Cloud Console.") - + val clientSecrets = GoogleClientSecrets.load(jsonFactory, InputStreamReader(inputStream)) - // Build flow and trigger user authorization request. - val flow = GoogleAuthorizationCodeFlow.Builder( + return GoogleAuthorizationCodeFlow.Builder( httpTransport, jsonFactory, clientSecrets, SCOPES ) .setDataStoreFactory(FileDataStoreFactory(JavaFile(TOKENS_DIRECTORY_PATH))) .setAccessType("offline") .build() - + } + + private fun getCredentials(httpTransport: NetHttpTransport): Credential { + val flow = getFlow(httpTransport) val receiver = LocalServerReceiver.Builder().setPort(8888).build() // authorize("user") authorizes for the "user" user ID. return AuthorizationCodeInstalledApp(flow, receiver).authorize("user") } - private val driveService: Drive by lazy { - val httpTransport = GoogleNetHttpTransport.newTrustedTransport() - val credential = getCredentials(httpTransport) - - Drive.Builder(httpTransport, jsonFactory, credential) - .setApplicationName(applicationName) - .build() + private var _driveService: Drive? = null + + private fun getDriveService(): Drive { + if (_driveService == null) { + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val credential = getCredentials(httpTransport) + _driveService = Drive.Builder(httpTransport, jsonFactory, credential) + .setApplicationName(applicationName) + .build() + } + return _driveService!! } override suspend fun listFiles(parentId: String?): List = withContext(Dispatchers.IO) { val folderId = parentId ?: "root" val query = "'$folderId' in parents and trashed = false" - val result = driveService.files().list() + val result = getDriveService().files().list() .setQ(query) .setFields("nextPageToken, files(id, name, mimeType)") .execute() @@ -107,7 +111,7 @@ class GoogleDriveClient : CloudDriveClient { val mediaContent = ByteArrayContent(mimeType, content) - val file = driveService.files().create(fileMetadata, mediaContent) + val file = getDriveService().files().create(fileMetadata, mediaContent) .setFields("id, name, mimeType, parents") .execute() @@ -128,7 +132,7 @@ class GoogleDriveClient : CloudDriveClient { } } - val file = driveService.files().create(fileMetadata) + val file = getDriveService().files().create(fileMetadata) .setFields("id, name, mimeType") .execute() @@ -141,12 +145,12 @@ class GoogleDriveClient : CloudDriveClient { } override suspend fun deleteFile(fileId: String): Unit = withContext(Dispatchers.IO) { - driveService.files().delete(fileId).execute() + getDriveService().files().delete(fileId).execute() } override suspend fun downloadFile(fileId: String): ByteArray = withContext(Dispatchers.IO) { val outputStream = ByteArrayOutputStream() - driveService.files().get(fileId) + getDriveService().files().get(fileId) .executeMediaAndDownloadTo(outputStream) outputStream.toByteArray() } @@ -160,12 +164,12 @@ class GoogleDriveClient : CloudDriveClient { // We need to guess the mime type or retrieve it. For update, let's assume we keep existing or use generic. // But ByteArrayContent needs a type. // Let's fetch the file first to get the mimeType. - val existingFile = driveService.files().get(fileId).setFields("mimeType").execute() + val existingFile = getDriveService().files().get(fileId).setFields("mimeType").execute() val mimeType = existingFile.mimeType val mediaContent = ByteArrayContent(mimeType, content) - val updatedFile = driveService.files().update(fileId, fileMetadata, mediaContent) + val updatedFile = getDriveService().files().update(fileId, fileMetadata, mediaContent) .setFields("id, name, mimeType") .execute() @@ -177,7 +181,67 @@ class GoogleDriveClient : CloudDriveClient { ) } + override suspend fun isAuthorized(): Boolean = withContext(Dispatchers.IO) { + try { + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val flow = getFlow(httpTransport) + val credential = flow.loadCredential("user") + + if (credential == null) return@withContext false + + val refreshToken = credential.refreshToken + val expiresIn = credential.expiresInSeconds + // Authorized if we have a refresh token OR a valid access token + return@withContext refreshToken != null || (expiresIn != null && expiresIn > 60) + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + override suspend fun authorize(): Boolean = withContext(Dispatchers.IO) { + try { + // Force re-authorization or load existing + // accessing driveService triggers authorization via getCredentials + // But getCredentials calls `authorize("user")` + // If we are already authorized, this returns immediately. + // If not, it opens browser. + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val credential = getCredentials(httpTransport) + credential != null + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + override suspend fun signOut() = withContext(Dispatchers.IO) { + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val flow = getFlow(httpTransport) + flow.credentialDataStore.delete("user") + _driveService = null + } + + override suspend fun getUserInfo(): UserInfo? = withContext(Dispatchers.IO) { + if (!isAuthorized()) return@withContext null + try { + val about = getDriveService().about().get().setFields("user").execute() + val user = about.user + UserInfo( + name = user.displayName, + email = user.emailAddress, + photoUrl = user.photoLink + ) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + companion object { private const val MIME_TYPE_FOLDER = "application/vnd.google-apps.folder" } } + +private val googleDriveClientInstance by lazy { GoogleDriveClient() } +actual fun getCloudDriveClient(): CloudDriveClient = googleDriveClientInstance diff --git a/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 3ea2159..5ee1196 100644 --- a/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -29,4 +29,22 @@ class GoogleDriveClient : CloudDriveClient { override suspend fun updateFile(fileId: String, content: ByteArray): DriveFile { throw NotImplementedError("Google Drive is not supported on Web target yet.") } + + override suspend fun isAuthorized(): Boolean { + return false + } + + override suspend fun authorize(): Boolean { + throw NotImplementedError("Google Drive is not supported on Web target yet.") + } + + override suspend fun signOut() { + throw NotImplementedError("Google Drive is not supported on Web target yet.") + } + + override suspend fun getUserInfo(): UserInfo? { + return null + } } + +actual fun getCloudDriveClient(): CloudDriveClient = GoogleDriveClient() From 861c4ea8a590fdbb67b5ccc88f2b61be33127271 Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Tue, 20 Jan 2026 01:45:07 +0000 Subject: [PATCH 04/14] feat: integrate Google Drive Auth for Android --- composeApp/build.gradle.kts | 3 + .../io/github/smiling_pixel/MainActivity.kt | 10 + .../smiling_pixel/client/GoogleDriveClient.kt | 218 ++++++++++++++++-- .../client/GoogleSignInHelper.kt | 30 +++ gradle/libs.versions.toml | 2 + 5 files changed, 242 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index dd0eb97..831eeb8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/MainActivity.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/MainActivity.kt index 516ba74..8950d91 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/MainActivity.kt @@ -12,10 +12,20 @@ import io.github.smiling_pixel.filesystem.FileRepository import io.github.smiling_pixel.filesystem.fileManager import io.github.smiling_pixel.preference.AndroidContextProvider +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) + + GoogleSignInHelper.registerLauncher( + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + GoogleSignInHelper.onActivityResult(result) + } + ) + AndroidContextProvider.context = this.applicationContext // Build Room-backed repository on Android and pass it into App diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 757442d..25e0ddb 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -1,45 +1,221 @@ 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.http.javanet.NetHttpTransport +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.preference.AndroidContextProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.util.Collections + class GoogleDriveClient : CloudDriveClient { - override suspend fun listFiles(parentId: String?): List { - TODO("Not yet implemented") + + private val context: Context + get() = AndroidContextProvider.context + + private val jsonFactory = GsonFactory.getDefaultInstance() + private val appName = "MarkDay Diary" + private val MIME_TYPE_FOLDER = "application/vnd.google-apps.folder" + + 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 fun checkAndInitService(): Boolean { + if (driveService != null) return 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 true + } + } + return false } - override suspend fun createFile(name: String, content: ByteArray, mimeType: String, parentId: String?): DriveFile { - TODO("Not yet implemented") + 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 createFolder(name: String, parentId: String?): DriveFile { - TODO("Not yet implemented") + override suspend fun isAuthorized(): Boolean = withContext(Dispatchers.IO) { + checkAndInitService() } - override suspend fun deleteFile(fileId: String) { - TODO("Not yet implemented") + override suspend fun authorize(): Boolean = withContext(Dispatchers.Main) { + if (withContext(Dispatchers.IO) { checkAndInitService() }) return@withContext true + + 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) { + initService(email) + return@withContext true + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + false } - override suspend fun downloadFile(fileId: String): ByteArray { - TODO("Not yet implemented") + 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() + client.signOut().addOnCompleteListener { + driveService = null + deferred.complete(Unit) + } + deferred.await() } - override suspend fun updateFile(fileId: String, content: ByteArray): DriveFile { - TODO("Not yet implemented") + 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 isAuthorized(): Boolean { - return false + override suspend fun listFiles(parentId: String?): List = withContext(Dispatchers.IO) { + val folderId = parentId ?: "root" + val query = "'$folderId' in parents and trashed = false" + + 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 == 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 == MIME_TYPE_FOLDER + ) + } + + override suspend fun createFolder(name: String, parentId: String?): DriveFile = withContext(Dispatchers.IO) { + val fileMetadata = File().apply { + this.name = name + this.mimeType = 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 = MIME_TYPE_FOLDER, + isFolder = true + ) } - override suspend fun authorize(): Boolean { - TODO("Not yet implemented") + override suspend fun deleteFile(fileId: String): Unit = withContext(Dispatchers.IO) { + getService().files().delete(fileId).execute() } - override suspend fun signOut() { - TODO("Not yet implemented") + override suspend fun downloadFile(fileId: String): ByteArray = withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + getService().files().get(fileId) + .executeMediaAndDownloadTo(outputStream) + outputStream.toByteArray() } - override suspend fun getUserInfo(): UserInfo? { - return null + 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() + + DriveFile( + id = updatedFile.id, + name = updatedFile.name, + mimeType = updatedFile.mimeType, + isFolder = updatedFile.mimeType == MIME_TYPE_FOLDER + ) } } -actual fun getCloudDriveClient(): CloudDriveClient = GoogleDriveClient() +private val googleDriveClientInstance by lazy { GoogleDriveClient() } +actual fun getCloudDriveClient(): CloudDriveClient = googleDriveClientInstance \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt new file mode 100644 index 0000000..dcc8170 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt @@ -0,0 +1,30 @@ +package io.github.smiling_pixel.client + +import android.app.Activity +import android.content.Intent +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import kotlinx.coroutines.CompletableDeferred + +object GoogleSignInHelper { + private var launcher: ActivityResultLauncher? = null + private var authDeferred: CompletableDeferred? = null + + fun registerLauncher(launcher: ActivityResultLauncher) { + this.launcher = launcher + } + + fun onActivityResult(result: ActivityResult) { + authDeferred?.complete(result) + authDeferred = null + } + + suspend fun launchSignIn(intent: Intent): ActivityResult? { + val l = launcher ?: return null + val deferred = CompletableDeferred() + authDeferred = deferred + l.launch(intent) + return deferred.await() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d68515..545a071 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,8 +55,10 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } google-api-client = { module = "com.google.api-client:google-api-client", version = "2.7.0" } +google-api-client-android = { module = "com.google.api-client:google-api-client-android", version = "2.7.0" } google-api-services-drive = { module = "com.google.apis:google-api-services-drive", version = "v3-rev20241027-2.0.0" } google-oauth-client-jetty = { module = "com.google.oauth-client:google-oauth-client-jetty", version = "1.36.0" } +play-services-auth = { module = "com.google.android.gms:play-services-auth", version = "21.0.0" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From 19ab7a7970da66627a72de08a218f5d962dfb746 Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Wed, 21 Jan 2026 01:05:47 +0000 Subject: [PATCH 05/14] feat: update package name for Android integration --- composeApp/build.gradle.kts | 6 +++--- composeApp/src/androidMain/AndroidManifest.xml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 831eeb8..7e78b53 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -123,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 @@ -163,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" } } diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index de6216c..2cfba71 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -12,7 +12,7 @@ android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:name="io.github.smiling_pixel.MainActivity"> From 16d603ac95311023b2defdd6d2361a79a8ca76b4 Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Fri, 23 Jan 2026 02:05:11 +0000 Subject: [PATCH 06/14] refactor: fix indentation and clean imports --- .../smiling_pixel/client/GoogleDriveClient.kt | 31 +++++++++---------- .../client/GoogleSignInHelper.kt | 2 -- .../smiling_pixel/client/GoogleDriveClient.kt | 4 +-- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 25e0ddb..2c5a856 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -8,7 +8,6 @@ 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.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory import com.google.api.services.drive.Drive import com.google.api.services.drive.DriveScopes @@ -83,19 +82,19 @@ class GoogleDriveClient : CloudDriveClient { 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) { - initService(email) - return@withContext true - } - } - } catch (e: Exception) { - e.printStackTrace() - } + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(ApiException::class.java) + if (account != null) { + val email = account.email + if (email != null) { + initService(email) + return@withContext true + } + } + } catch (e: Exception) { + e.printStackTrace() + } } false } @@ -106,8 +105,8 @@ class GoogleDriveClient : CloudDriveClient { val deferred = kotlinx.coroutines.CompletableDeferred() client.signOut().addOnCompleteListener { - driveService = null - deferred.complete(Unit) + driveService = null + deferred.complete(Unit) } deferred.await() } diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt index dcc8170..630234a 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt @@ -1,10 +1,8 @@ package io.github.smiling_pixel.client -import android.app.Activity import android.content.Intent import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.IntentSenderRequest import kotlinx.coroutines.CompletableDeferred object GoogleSignInHelper { diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index b43fde5..9a294b4 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -210,8 +210,8 @@ class GoogleDriveClient : CloudDriveClient { val credential = getCredentials(httpTransport) credential != null } catch (e: Exception) { - e.printStackTrace() - false + e.printStackTrace() + false } } From 0bff97ab588ed4f7e36cc74a961ccb57345bd026 Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Sat, 24 Jan 2026 01:55:47 +0000 Subject: [PATCH 07/14] feat: implement mutex for concurrent sign-in requests and update credentials file instructions --- .../client/GoogleSignInHelper.kt | 22 +++++++++++++++---- .../smiling_pixel/client/GoogleDriveClient.kt | 17 +++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt index 630234a..9fe78e0 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt @@ -4,10 +4,17 @@ import android.content.Intent import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock object GoogleSignInHelper { private var launcher: ActivityResultLauncher? = null private var authDeferred: CompletableDeferred? = null + + // Mutex to handle concurrent authorization requests. + // This prevents race conditions where a second sign-in request could overwrite + // the 'authDeferred' of a pending request, causing the first request to never complete. + private val mutex = Mutex() fun registerLauncher(launcher: ActivityResultLauncher) { this.launcher = launcher @@ -20,9 +27,16 @@ object GoogleSignInHelper { suspend fun launchSignIn(intent: Intent): ActivityResult? { val l = launcher ?: return null - val deferred = CompletableDeferred() - authDeferred = deferred - l.launch(intent) - return deferred.await() + + // Use a Mutex to ensure only one sign-in flow is active at a time. + // We wait for the lock, then create the deferred, launch the intent, and wait for the result. + // The lock is held until the result is received (or the coroutine is cancelled), + // preventing other coroutines from overwriting 'authDeferred' in the meantime. + return mutex.withLock { + val deferred = CompletableDeferred() + authDeferred = deferred + l.launch(intent) + deferred.await() + } } } diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 9a294b4..34cb42a 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -29,6 +29,14 @@ import java.io.File as JavaFile * https://developers.google.com/workspace/drive/api/quickstart/java * https://developers.google.com/workspace/drive/api/guides/search-files * https://developers.google.com/workspace/drive/api/guides/manage-files + * + * IMPORTANT: This implementation requires a `credentials.json` file in the `src/jvmMain/resources` directory. + * This file contains the OAuth 2.0 Client ID and Client Secret. + * You can obtain this file from the Google Cloud Console: + * 1. Go to APIs & Services > Credentials. + * 2. Create an OAuth 2.0 Client ID for "Desktop app". + * 3. Download the JSON file and rename it to `credentials.json`. + * 4. Place it in `composeApp/src/jvmMain/resources/`. */ class GoogleDriveClient : CloudDriveClient { @@ -45,11 +53,18 @@ class GoogleDriveClient : CloudDriveClient { * If modifying these scopes, delete your previously saved tokens/ folder. */ private val SCOPES = listOf(DriveScopes.DRIVE_FILE) + /** + * Path to the credentials file in resources. + * Ensure this file exists, otherwise [getFlow] will throw a [FileNotFoundException]. + */ private val CREDENTIALS_FILE_PATH = "/credentials.json" private fun getFlow(httpTransport: NetHttpTransport): GoogleAuthorizationCodeFlow { val inputStream = GoogleDriveClient::class.java.getResourceAsStream(CREDENTIALS_FILE_PATH) - ?: throw FileNotFoundException("Resource not found: $CREDENTIALS_FILE_PATH. Please obtain credentials.json from Google Cloud Console.") + ?: throw FileNotFoundException( + "Resource not found: $CREDENTIALS_FILE_PATH. " + + "Please obtain credentials.json from Google Cloud Console and place it in src/jvmMain/resources." + ) val clientSecrets = GoogleClientSecrets.load(jsonFactory, InputStreamReader(inputStream)) From 2d5c9e6b9fd414108bdbba0042dbe6b1bad0c593 Mon Sep 17 00:00:00 2001 From: Xun Zhou <73827509+SmilingPixel@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:48:06 +0800 Subject: [PATCH 08/14] doc: document about Android applicationId Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- composeApp/src/androidMain/AndroidManifest.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 2cfba71..a86ee07 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -10,6 +10,11 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@android:style/Theme.Material.Light.NoActionBar"> + From d3e207bf0eb1ff6f70f7fdcacb08370d22608148 Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Sun, 25 Jan 2026 07:41:05 +0000 Subject: [PATCH 09/14] feat: replace printStackTrace with Logger for error handling --- .../io/github/smiling_pixel/client/GoogleDriveClient.kt | 4 +++- .../io/github/smiling_pixel/client/GoogleDriveClient.kt | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 2c5a856..8714751 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -12,6 +12,8 @@ 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 @@ -93,7 +95,7 @@ class GoogleDriveClient : CloudDriveClient { } } } catch (e: Exception) { - e.printStackTrace() + Logger.e("GoogleDriveClient", "Google Sign-In failed: ${e.message}") } } false diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 34cb42a..ed86bed 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -13,6 +13,8 @@ import com.google.api.client.util.store.FileDataStoreFactory 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream @@ -209,7 +211,7 @@ class GoogleDriveClient : CloudDriveClient { // Authorized if we have a refresh token OR a valid access token return@withContext refreshToken != null || (expiresIn != null && expiresIn > 60) } catch (e: Exception) { - e.printStackTrace() + Logger.e("GoogleDriveClient", "Failed to check authorization status: ${e.message}") false } } @@ -225,7 +227,7 @@ class GoogleDriveClient : CloudDriveClient { val credential = getCredentials(httpTransport) credential != null } catch (e: Exception) { - e.printStackTrace() + Logger.e("GoogleDriveClient", "Failed to authorize: ${e.message}") false } } @@ -248,7 +250,7 @@ class GoogleDriveClient : CloudDriveClient { photoUrl = user.photoLink ) } catch (e: Exception) { - e.printStackTrace() + Logger.e("GoogleDriveClient", "Failed to get user info: ${e.message}") null } } From cc0f444ee02ecf8fcbb2e085443f87018a5f8b33 Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Sun, 25 Jan 2026 07:46:09 +0000 Subject: [PATCH 10/14] refactor: code clean --- .../smiling_pixel/client/GoogleDriveClient.kt | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index ed86bed..1d3b266 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -43,23 +43,31 @@ import java.io.File as JavaFile class GoogleDriveClient : CloudDriveClient { private val jsonFactory = GsonFactory.getDefaultInstance() - private val applicationName = "MarkDay Diary" - /** - * Directory to store authorization tokens for this application. - */ - private val TOKENS_DIRECTORY_PATH = "tokens" + private val applicationName = APPLICATION_NAME + + companion object { + private const val APPLICATION_NAME = "MarkDay Diary" + } + + companion object { + /** + * Directory to store authorization tokens for this application. + */ + private const val TOKENS_DIRECTORY_PATH = "tokens" + + /** + * Path to the credentials file in resources. + * Ensure this file exists, otherwise [getFlow] will throw a [FileNotFoundException]. + */ + private const val CREDENTIALS_FILE_PATH = "/credentials.json" + } /** * Global instance of the scopes required by this quickstart. * If modifying these scopes, delete your previously saved tokens/ folder. */ private val SCOPES = listOf(DriveScopes.DRIVE_FILE) - /** - * Path to the credentials file in resources. - * Ensure this file exists, otherwise [getFlow] will throw a [FileNotFoundException]. - */ - private val CREDENTIALS_FILE_PATH = "/credentials.json" private fun getFlow(httpTransport: NetHttpTransport): GoogleAuthorizationCodeFlow { val inputStream = GoogleDriveClient::class.java.getResourceAsStream(CREDENTIALS_FILE_PATH) @@ -85,17 +93,17 @@ class GoogleDriveClient : CloudDriveClient { return AuthorizationCodeInstalledApp(flow, receiver).authorize("user") } - private var _driveService: Drive? = null + private var driveServiceCache: Drive? = null private fun getDriveService(): Drive { - if (_driveService == null) { + if (driveServiceCache == null) { val httpTransport = GoogleNetHttpTransport.newTrustedTransport() val credential = getCredentials(httpTransport) - _driveService = Drive.Builder(httpTransport, jsonFactory, credential) + driveServiceCache = Drive.Builder(httpTransport, jsonFactory, credential) .setApplicationName(applicationName) .build() } - return _driveService!! + return driveServiceCache!! } override suspend fun listFiles(parentId: String?): List = withContext(Dispatchers.IO) { @@ -236,7 +244,7 @@ class GoogleDriveClient : CloudDriveClient { val httpTransport = GoogleNetHttpTransport.newTrustedTransport() val flow = getFlow(httpTransport) flow.credentialDataStore.delete("user") - _driveService = null + driveServiceCache = null } override suspend fun getUserInfo(): UserInfo? = withContext(Dispatchers.IO) { From 11545248d556c8fbd50612f56fe06c0a1be38ca7 Mon Sep 17 00:00:00 2001 From: Xun Zhou <73827509+SmilingPixel@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:48:49 +0800 Subject: [PATCH 11/14] doc: CloudDriveClient interface methods Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../smiling_pixel/client/CloudDriveClient.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt index 2b7f7d5..fe21312 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt @@ -66,9 +66,39 @@ interface CloudDriveClient { */ suspend fun updateFile(fileId: String, content: ByteArray): DriveFile + /** + * Checks whether the client is currently authorized to access the cloud drive. + * This method should not trigger any user interaction. + * + * @return `true` if the client has a valid authorization/session, `false` otherwise. + * @throws Exception If the authorization state cannot be determined due to an underlying error. + */ suspend fun isAuthorized(): Boolean + + /** + * Initiates the authorization or sign-in flow required to access the cloud drive. + * + * @return `true` if authorization completes successfully and the client is ready to use, + * `false` if the user cancels or authorization otherwise fails without throwing. + * @throws Exception If an unrecoverable error occurs during authorization (for example, + * network failures or provider-specific errors). + */ suspend fun authorize(): Boolean + + /** + * Signs out the current user and revokes or clears any stored authorization. + * + * @throws Exception If an error occurs while signing out or revoking authorization. + */ suspend fun signOut() + + /** + * Retrieves basic information about the currently authorized user. + * + * @return A [UserInfo] object for the current user, or `null` if there is no authorized user. + * @throws Exception If user information cannot be retrieved due to authorization or + * connectivity issues. + */ suspend fun getUserInfo(): UserInfo? } From 558ff326f7244100d8e7ee55ce73bf021a90fa7f Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Tue, 27 Jan 2026 01:41:27 +0000 Subject: [PATCH 12/14] feat: enhance GoogleSignInHelper with logging and improved authDeferred management --- .../client/GoogleSignInHelper.kt | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt index 9fe78e0..590b0b1 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt @@ -1,6 +1,7 @@ package io.github.smiling_pixel.client import android.content.Intent +import android.util.Log import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import kotlinx.coroutines.CompletableDeferred @@ -21,22 +22,41 @@ object GoogleSignInHelper { } fun onActivityResult(result: ActivityResult) { + if (authDeferred == null) { + Log.w("GoogleSignInHelper", "onActivityResult called but authDeferred is null. Unexpected activity result or cancelled sign-in.") + } authDeferred?.complete(result) authDeferred = null } suspend fun launchSignIn(intent: Intent): ActivityResult? { - val l = launcher ?: return null - + // Use a Mutex to ensure only one sign-in flow is active at a time. // We wait for the lock, then create the deferred, launch the intent, and wait for the result. // The lock is held until the result is received (or the coroutine is cancelled), // preventing other coroutines from overwriting 'authDeferred' in the meantime. - return mutex.withLock { - val deferred = CompletableDeferred() + + val l = launcher ?: return null + + val deferred = CompletableDeferred() + + // Update authDeferred safely. If a previous request is pending, cancel it + // so we don't block indefinitely (e.g. if the user abandoned the previous sign-in). + mutex.withLock { + authDeferred?.cancel() authDeferred = deferred l.launch(intent) - deferred.await() + } + + try { + return deferred.await() + } finally { + // Ensure proper cleanup. Only clear if the current deferred is the one we set. + mutex.withLock { + if (authDeferred === deferred) { + authDeferred = null + } + } } } } From b9a9eedbc12d0cf3464180c9fae6a79f152b1dec Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Wed, 28 Jan 2026 01:37:23 +0000 Subject: [PATCH 13/14] feat: enhance SettingsScreen with loading state and error handling --- .../io/github/smiling_pixel/SettingsScreen.kt | 103 +++++++++++++++--- 1 file changed, 87 insertions(+), 16 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt index c6a5393..0a5e42c 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -23,6 +26,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.Lifecycle import io.github.smiling_pixel.client.UserInfo import io.github.smiling_pixel.client.getCloudDriveClient import io.github.smiling_pixel.preference.getSettingsRepository @@ -38,14 +43,31 @@ fun SettingsScreen() { val cloudDriveClient = remember { getCloudDriveClient() } var userInfo by remember { mutableStateOf(null) } var isAuthorized by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - isAuthorized = cloudDriveClient.isAuthorized() - if (isAuthorized) { - userInfo = cloudDriveClient.getUserInfo() + fun checkAuthStatus() { + scope.launch { + try { + isAuthorized = cloudDriveClient.isAuthorized() + if (isAuthorized) { + userInfo = cloudDriveClient.getUserInfo() + } else { + userInfo = null + } + } catch (e: Exception) { + isAuthorized = false + userInfo = null + // Fail silently on background check or set error if critical + // errorMessage = "Failed to refresh status: ${e.message}" + } } } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + checkAuthStatus() + } + Column( modifier = Modifier .fillMaxSize() @@ -81,29 +103,78 @@ fun SettingsScreen() { style = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.height(8.dp)) + + if (errorMessage != null) { + Text( + text = errorMessage!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + } if (isAuthorized) { Text("Signed in as: ${userInfo?.name ?: "Loading..."}") Text("Email: ${userInfo?.email ?: ""}") Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { - scope.launch { - cloudDriveClient.signOut() - isAuthorized = false - userInfo = null + Button( + onClick = { + scope.launch { + isLoading = true + errorMessage = null + try { + cloudDriveClient.signOut() + isAuthorized = false + userInfo = null + } catch (e: Exception) { + errorMessage = "Sign out failed: ${e.message}" + } finally { + isLoading = false + } + } + }, + enabled = !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) } - }) { Text("Revoke Authorization") } } else { - Button(onClick = { - scope.launch { - if (cloudDriveClient.authorize()) { - isAuthorized = true - userInfo = cloudDriveClient.getUserInfo() + Button( + onClick = { + scope.launch { + isLoading = true + errorMessage = null + try { + if (cloudDriveClient.authorize()) { + isAuthorized = true + userInfo = cloudDriveClient.getUserInfo() + } else { + errorMessage = "Authorization was cancelled or failed." + } + } catch (e: Exception) { + errorMessage = "Authorization error: ${e.message}" + } finally { + isLoading = false + } } + }, + enabled = !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) } - }) { Text("Authorize Google Drive") } } From 09bba31b6855cc952e9cb8828180a9b5b26451b2 Mon Sep 17 00:00:00 2001 From: SmilingPixel Date: Thu, 29 Jan 2026 02:01:31 +0000 Subject: [PATCH 14/14] feat: improve GoogleDriveClient with context initialization check and optimize authorization flow --- .../smiling_pixel/client/GoogleDriveClient.kt | 83 ++++++++++++++++--- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt index 8714751..eee362c 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -17,18 +17,38 @@ 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() = AndroidContextProvider.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 MIME_TYPE_FOLDER = "application/vnd.google-apps.folder" + private val serviceMutex = Mutex() + @Volatile private var driveService: Drive? = null private fun getService(): Drive { @@ -36,20 +56,24 @@ class GoogleDriveClient : CloudDriveClient { } // Checking auth state and initializing service if possible - private fun checkAndInitService(): Boolean { + private suspend fun checkAndInitService(): Boolean { if (driveService != null) return 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 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 } - return false } private fun initService(email: String) { @@ -69,9 +93,11 @@ class GoogleDriveClient : CloudDriveClient { checkAndInitService() } + The authorize() function in the Android implementation switches to Dispatchers.Main unnecessarily at the start. The function first checks authorization status using IO dispatcher (line 71), then the rest of the authorization logic also runs on Main. Since GoogleSignInHelper.launchSignIn likely needs to be on Main for launching the intent, consider restructuring the function to only switch to Main when necessary, and perform the initial authorization check on IO for better performance. + +Suggested change override suspend fun authorize(): Boolean = withContext(Dispatchers.Main) { if (withContext(Dispatchers.IO) { checkAndInitService() }) return@withContext true - val driveScope = Scope(DriveScopes.DRIVE_FILE) val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) .requestEmail() @@ -95,10 +121,41 @@ class GoogleDriveClient : CloudDriveClient { } } } catch (e: Exception) { - Logger.e("GoogleDriveClient", "Google Sign-In failed: ${e.message}") + e.printStackTrace() } } false + 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) { + 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) {