Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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)
}
)

Comment on lines 16 to +28
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

The GoogleSignInHelper.registerLauncher is called in MainActivity.onCreate before super.onCreate() is called (line 21). This violates Android best practices and could lead to issues because the Activity isn't fully initialized. The registration should happen after super.onCreate() and enableEdgeToEdge() calls. Additionally, since onCreate can be called multiple times (e.g., on configuration changes), registering the launcher multiple times could cause issues. Consider checking if the launcher is already registered or handle this more carefully.

Suggested change
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
GoogleSignInHelper.registerLauncher(
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
GoogleSignInHelper.onActivityResult(result)
}
)
companion object {
private var launcherRegistered: Boolean = false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (!launcherRegistered) {
GoogleSignInHelper.registerLauncher(
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
GoogleSignInHelper.onActivityResult(result)
}
)
launcherRegistered = true
}

Copilot uses AI. Check for mistakes.
AndroidContextProvider.context = this.applicationContext

// Build Room-backed repository on Android and pass it into App
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
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 MIME_TYPE_FOLDER = "application/vnd.google-apps.folder"

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

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()
.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 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) {
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()
val client = GoogleSignIn.getClient(context, gso)

val deferred = kotlinx.coroutines.CompletableDeferred<Unit>()
client.signOut().addOnCompleteListener {
driveService = null
deferred.complete(Unit)
}
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"
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 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()

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

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