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
2 changes: 1 addition & 1 deletion composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<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)
}
)

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,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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

The import com.google.api.client.http.javanet.NetHttpTransport is unused in the Android implementation. This import is only relevant for JVM-based implementations. Consider removing it to keep the imports clean.

Suggested change
import com.google.api.client.http.javanet.NetHttpTransport

Copilot uses AI. Check for mistakes.
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 {

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
}

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 = 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)
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

There is inconsistent indentation on line 86. The line uses an extra space before the val keyword, while lines 87-95 have proper indentation. This should be aligned with the surrounding code for consistency.

Suggested change
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)

Copilot uses AI. Check for mistakes.
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()
}
Comment on lines +87 to +98
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

There is inconsistent indentation on lines 87-88 and 96-97. These lines use an extra space before the keywords, while surrounding code has proper indentation. The indentation should be aligned consistently with the rest of the code block.

Copilot uses AI. Check for mistakes.
}
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)
Comment on lines +109 to +110
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

There is inconsistent indentation on lines 108-110. These lines use an extra space before the keywords, while surrounding code has proper indentation. The indentation should be aligned consistently with the rest of the code block.

Suggested change
driveService = null
deferred.complete(Unit)
driveService = null
deferred.complete(Unit)

Copilot uses AI. Check for mistakes.
}
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.github.smiling_pixel.client

import android.app.Activity
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

The import android.app.Activity is unused in this file. Consider removing it to keep the imports clean.

Suggested change
import android.app.Activity

Copilot uses AI. Check for mistakes.
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

The import androidx.activity.result.IntentSenderRequest is unused in this file. Consider removing it to keep the imports clean.

Suggested change
import androidx.activity.result.IntentSenderRequest

Copilot uses AI. Check for mistakes.
import kotlinx.coroutines.CompletableDeferred

object GoogleSignInHelper {
private var launcher: ActivityResultLauncher<Intent>? = null
private var authDeferred: CompletableDeferred<ActivityResult>? = null

fun registerLauncher(launcher: ActivityResultLauncher<Intent>) {
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<ActivityResult>()
authDeferred = deferred
l.launch(intent)
return deferred.await()
}
Comment on lines +23 to +29
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

The GoogleSignInHelper object has a potential race condition. If multiple coroutines call launchSignIn simultaneously, the second call will overwrite authDeferred before the first completes, causing the first caller to never receive its result. Consider using a thread-safe queue or mutex to handle concurrent authorization requests.

Copilot uses AI. Check for mistakes.
}
Loading
Loading