diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index af264df..7e78b53 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) @@ -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) @@ -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 @@ -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" } } diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index de6216c..a86ee07 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -10,9 +10,14 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@android:style/Theme.Material.Light.NoActionBar"> + + android:name="io.github.smiling_pixel.MainActivity"> 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..4679904 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/MainActivity.kt @@ -11,13 +11,28 @@ import io.github.smiling_pixel.database.DiaryRepository import io.github.smiling_pixel.filesystem.FileRepository import io.github.smiling_pixel.filesystem.fileManager import io.github.smiling_pixel.preference.AndroidContextProvider +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch + +import androidx.activity.result.contract.ActivityResultContracts +import io.github.smiling_pixel.client.GoogleSignInHelper class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() super.onCreate(savedInstanceState) + enableEdgeToEdge() + AndroidContextProvider.context = this.applicationContext + GoogleSignInHelper.registerLauncher( + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + lifecycleScope.launch { + GoogleSignInHelper.onActivityResult(result) + } + } + ) + // Build Room-backed repository on Android and pass it into App val db = createDatabase(this) val repo = DiaryRepository(db.diaryDao()) @@ -27,6 +42,11 @@ class MainActivity : ComponentActivity() { App(repo, fileRepo) } } + + override fun onDestroy() { + super.onDestroy() + GoogleSignInHelper.unregisterLauncher() + } } @Preview 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..9373c49 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -0,0 +1,256 @@ +package io.github.smiling_pixel.client + +import android.content.Context +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.Scope +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.extensions.android.http.AndroidHttp +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import io.github.smiling_pixel.util.Logger +import io.github.smiling_pixel.util.e +import io.github.smiling_pixel.preference.AndroidContextProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.ByteArrayOutputStream +import java.util.Collections + +class GoogleDriveClient : CloudDriveClient { + + /** + * Retrieves the Android Application Context. + * + * This property accesses [AndroidContextProvider.context]. Ensure that [AndroidContextProvider.context] + * is initialized (typically in `MainActivity.onCreate`) before any methods of this client are called. + * + * @throws IllegalStateException if [AndroidContextProvider.context] has not been initialized yet. + */ + private val context: Context + get() = try { + AndroidContextProvider.context + } catch (e: UninitializedPropertyAccessException) { + throw IllegalStateException( + "AndroidContextProvider.context is not initialized. " + + "Ensure it is set before using GoogleDriveClient.", + e + ) + } + + private val jsonFactory = GsonFactory.getDefaultInstance() + private val appName = "MarkDay Diary" + + private val serviceMutex = Mutex() + @Volatile + private var driveService: Drive? = null + + private fun getService(): Drive { + return driveService ?: throw IllegalStateException("Google Drive not authorized") + } + + // Checking auth state and initializing service if possible + private suspend fun checkAndInitService(): Boolean { + if (driveService != null) return true + + return serviceMutex.withLock { + if (driveService != null) return@withLock true + + val account = GoogleSignIn.getLastSignedInAccount(context) + val driveScope = Scope(DriveScopes.DRIVE_FILE) + + if (account != null && GoogleSignIn.hasPermissions(account, driveScope)) { + val email = account.email + if (email != null) { + initService(email) + return@withLock true + } + } + false + } + } + + private fun initService(email: String) { + val credential = GoogleAccountCredential.usingOAuth2( + context, Collections.singleton(DriveScopes.DRIVE_FILE) + ) + credential.selectedAccountName = email + + driveService = Drive.Builder( + AndroidHttp.newCompatibleTransport(), + jsonFactory, + credential + ).setApplicationName(appName).build() + } + + override suspend fun isAuthorized(): Boolean = withContext(Dispatchers.IO) { + checkAndInitService() + } + + override suspend fun authorize(): Boolean { + // First, try to initialize the service on IO without switching to Main unnecessarily + if (withContext(Dispatchers.IO) { checkAndInitService() }) return true + // If not yet authorized, perform the sign-in flow on the main thread + return withContext(Dispatchers.Main) { + val driveScope = Scope(DriveScopes.DRIVE_FILE) + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(driveScope) + .build() + val client = GoogleSignIn.getClient(context, gso) + val signInIntent = client.signInIntent + val result = GoogleSignInHelper.launchSignIn(signInIntent) + if (result != null && result.resultCode == android.app.Activity.RESULT_OK) { + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(ApiException::class.java) + if (account != null) { + val email = account.email + if (email != null) { + serviceMutex.withLock { + initService(email) + } + return@withContext true + } + } + } catch (e: Exception) { + Logger.e("GoogleDriveClient", "Authorization failed: ${e.message}") + e.printStackTrace() + } + } + false + } + } + + override suspend fun signOut() = withContext(Dispatchers.Main) { + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build() + val client = GoogleSignIn.getClient(context, gso) + + val deferred = kotlinx.coroutines.CompletableDeferred() + client.signOut().addOnCompleteListener { task -> + if (task.isSuccessful) { + driveService = null + deferred.complete(Unit) + } else { + val e = task.exception ?: RuntimeException("Sign out failed") + Logger.e("GoogleDriveClient", "Sign out failed: ${e.message}") + deferred.completeExceptionally(e) + } + } + deferred.await() + } + + override suspend fun getUserInfo(): UserInfo? = withContext(Dispatchers.IO) { + if (!checkAndInitService()) return@withContext null + val account = GoogleSignIn.getLastSignedInAccount(context) ?: return@withContext null + val photoUrl = account.photoUrl?.toString() + UserInfo( + name = account.displayName ?: "", + email = account.email ?: "", + photoUrl = photoUrl + ) + } + + override suspend fun listFiles(parentId: String?): List = withContext(Dispatchers.IO) { + val folderId = parentId ?: "root" + // Sanitize folderId to prevent injection + val sanitizedFolderId = folderId.replace("'", "\\'") + val query = "'$sanitizedFolderId' 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 == CloudDriveClient.MIME_TYPE_FOLDER + ) + } ?: emptyList() + } + + override suspend fun createFile(name: String, content: ByteArray, mimeType: String, parentId: String?): DriveFile = withContext(Dispatchers.IO) { + val fileMetadata = File().apply { + this.name = name + this.mimeType = mimeType + if (parentId != null) { + this.parents = listOf(parentId) + } + } + + val mediaContent = ByteArrayContent(mimeType, content) + + val file = getService().files().create(fileMetadata, mediaContent) + .setFields("id, name, mimeType, parents") + .execute() + + DriveFile( + id = file.id, + name = file.name, + mimeType = file.mimeType, + isFolder = file.mimeType == CloudDriveClient.MIME_TYPE_FOLDER + ) + } + + override suspend fun createFolder(name: String, parentId: String?): DriveFile = withContext(Dispatchers.IO) { + val fileMetadata = File().apply { + this.name = name + this.mimeType = CloudDriveClient.MIME_TYPE_FOLDER + if (parentId != null) { + this.parents = listOf(parentId) + } + } + + val file = getService().files().create(fileMetadata) + .setFields("id, name, mimeType") + .execute() + + DriveFile( + id = file.id, + name = file.name, + mimeType = CloudDriveClient.MIME_TYPE_FOLDER, + isFolder = true + ) + } + + override suspend fun deleteFile(fileId: String): Unit = withContext(Dispatchers.IO) { + getService().files().delete(fileId).execute() + } + + override suspend fun downloadFile(fileId: String): ByteArray = withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + getService().files().get(fileId) + .executeMediaAndDownloadTo(outputStream) + outputStream.toByteArray() + } + + override suspend fun updateFile(fileId: String, content: ByteArray): DriveFile = withContext(Dispatchers.IO) { + val fileMetadata = File() + val existingFile = getService().files().get(fileId).setFields("mimeType").execute() + val mimeType = existingFile.mimeType + + val mediaContent = ByteArrayContent(mimeType, content) + + val updatedFile = getService().files().update(fileId, fileMetadata, mediaContent) + .setFields("id, name, mimeType") + .execute() + + DriveFile( + id = updatedFile.id, + name = updatedFile.name, + mimeType = updatedFile.mimeType, + isFolder = updatedFile.mimeType == CloudDriveClient.MIME_TYPE_FOLDER + ) + } +} + +private val googleDriveClientInstance by lazy { GoogleDriveClient() } +actual fun getCloudDriveClient(): CloudDriveClient = googleDriveClientInstance \ 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..e5f8e6c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt @@ -0,0 +1,70 @@ +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 +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 + } + + fun unregisterLauncher() { + this.launcher = null + } + + // Ensure thread-safe access. + // Otherwise, if onActivityResult is called concurrently with launchSignIn, the deferred could be completed and then immediately set to null, or vice versa, leading to inconsistent state. + suspend fun onActivityResult(result: ActivityResult) { + mutex.withLock { + 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? { + + // 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. + + 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) + } + + 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 + } + } + } + } +} 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..5b9823c 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 @@ -14,11 +16,22 @@ 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.CircularProgressIndicator 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.rememberUpdatedState 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 +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.launch @Composable @@ -28,6 +41,44 @@ 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) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var isCheckingAuth by remember { mutableStateOf(false) } + + val checkAuthStatus by rememberUpdatedState { + // use isCheckingAuth to prevent concurrent execution of the authentication status check + if (!isCheckingAuth) { + isCheckingAuth = true + scope.launch { + try { + isAuthorized = cloudDriveClient.isAuthorized() + if (isAuthorized) { + userInfo = cloudDriveClient.getUserInfo() + } else { + userInfo = null + } + } catch (e: CancellationException) { + // Don't catch structured concurrency cancellation exceptions + throw e + } catch (e: Exception) { + isAuthorized = false + userInfo = null + // Fail silently on background check or set error if critical + // errorMessage = "Failed to refresh status: ${e.message}" + } finally { + isCheckingAuth = false + } + } + } + } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + checkAuthStatus() + } + Column( modifier = Modifier .fillMaxSize() @@ -56,6 +107,95 @@ 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)) + + errorMessage?.let { msg -> + Text( + text = msg, + 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 { + isLoading = true + errorMessage = null + try { + cloudDriveClient.signOut() + isAuthorized = false + userInfo = null + } catch (e: CancellationException) { + // Don't catch structured concurrency cancellation exceptions + throw e + } 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 { + isLoading = true + errorMessage = null + try { + if (cloudDriveClient.authorize()) { + isAuthorized = true + userInfo = cloudDriveClient.getUserInfo() + } else { + errorMessage = "Authorization was cancelled or failed." + } + } catch (e: CancellationException) { + // Don't catch structured concurrency cancellation exceptions + throw e + } 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") + } + } + 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 new file mode 100644 index 0000000..9edfa51 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt @@ -0,0 +1,110 @@ +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 +) + +data class UserInfo( + val name: String, + val email: String, + val photoUrl: String? = null +) + +/** + * Client interface for accessing and managing files on cloud drives. + */ +interface CloudDriveClient { + companion object { + const val MIME_TYPE_FOLDER = "application/vnd.google-apps.folder" + } + + /** + * 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 + + /** + * 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? +} + +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 new file mode 100644 index 0000000..6ffa651 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -0,0 +1,294 @@ +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 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 +import java.io.FileNotFoundException +import java.io.InputStreamReader +import java.io.File as JavaFile + +/** + * 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 + * + * 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 { + + private val jsonFactory = GsonFactory.getDefaultInstance() + + private val applicationName = APPLICATION_NAME + + companion object { + private const val APPLICATION_NAME = "MarkDay Diary" + + /** + * 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. + * + * WARNING: If modifying these scopes, you MUST delete your previously saved "tokens/" folder. + * Failing to do so will result in authorization errors because the stored credentials will not have the new scopes. + * + * Current scope [DriveScopes.DRIVE_FILE] only grants access to files created by this app. + */ + private val scopes = listOf(DriveScopes.DRIVE_FILE) + + 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 and place it in src/jvmMain/resources." + ) + + val clientSecrets = InputStreamReader(inputStream).use { reader -> + GoogleClientSecrets.load(jsonFactory, reader) + } + + 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) + // Use port 0 to let the system assign an available port automatically + val receiver = LocalServerReceiver.Builder().setPort(0).build() + // authorize("user") authorizes for the "user" user ID. + return AuthorizationCodeInstalledApp(flow, receiver).authorize("user") + } + + @Volatile + private var driveServiceCache: Drive? = null + + private fun getDriveService(): Drive { + val cached = driveServiceCache + if (cached != null) return cached + + return synchronized(this) { + val cachedInLock = driveServiceCache + if (cachedInLock != null) { + cachedInLock + } else { + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val credential = getCredentials(httpTransport) + val newService = Drive.Builder(httpTransport, jsonFactory, credential) + .setApplicationName(applicationName) + .build() + driveServiceCache = newService + newService + } + } + } + + override suspend fun listFiles(parentId: String?): List = withContext(Dispatchers.IO) { + val folderId = parentId ?: "root" + // Sanitize folderId to prevent injection + val sanitizedFolderId = folderId.replace("'", "\\'") + val query = "'$sanitizedFolderId' in parents and trashed = false" + + val result = getDriveService().files().list() + .setQ(query) + .setFields("nextPageToken, files(id, name, mimeType)") + .execute() + + result.files?.map { file -> + DriveFile( + id = file.id, + name = file.name, + mimeType = file.mimeType, + isFolder = file.mimeType == CloudDriveClient.MIME_TYPE_FOLDER + ) + } ?: emptyList() + } + + override suspend fun createFile(name: String, content: ByteArray, mimeType: String, parentId: String?): DriveFile = withContext(Dispatchers.IO) { + val fileMetadata = File().apply { + this.name = name + this.mimeType = mimeType + if (parentId != null) { + this.parents = listOf(parentId) + } + } + + val mediaContent = ByteArrayContent(mimeType, content) + + val file = getDriveService().files().create(fileMetadata, mediaContent) + .setFields("id, name, mimeType, parents") + .execute() + + DriveFile( + id = file.id, + name = file.name, + mimeType = file.mimeType, + isFolder = file.mimeType == CloudDriveClient.MIME_TYPE_FOLDER + ) + } + + override suspend fun createFolder(name: String, parentId: String?): DriveFile = withContext(Dispatchers.IO) { + val fileMetadata = File().apply { + this.name = name + this.mimeType = CloudDriveClient.MIME_TYPE_FOLDER + if (parentId != null) { + this.parents = listOf(parentId) + } + } + + val file = getDriveService().files().create(fileMetadata) + .setFields("id, name, mimeType") + .execute() + + DriveFile( + id = file.id, + name = file.name, + mimeType = CloudDriveClient.MIME_TYPE_FOLDER, + isFolder = true + ) + } + + override suspend fun deleteFile(fileId: String): Unit = withContext(Dispatchers.IO) { + getDriveService().files().delete(fileId).execute() + } + + override suspend fun downloadFile(fileId: String): ByteArray = withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + getDriveService().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 = getDriveService().files().get(fileId).setFields("mimeType").execute() + val mimeType = existingFile.mimeType + + val mediaContent = ByteArrayContent(mimeType, content) + + val updatedFile = getDriveService().files().update(fileId, fileMetadata, mediaContent) + .setFields("id, name, mimeType") + .execute() + + DriveFile( + id = updatedFile.id, + name = updatedFile.name, + mimeType = updatedFile.mimeType, + isFolder = updatedFile.mimeType == CloudDriveClient.MIME_TYPE_FOLDER + ) + } + + 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) { + Logger.e("GoogleDriveClient", "Failed to check authorization status: ${e.message}") + 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) + + if (credential != null) { + driveServiceCache = Drive.Builder(httpTransport, jsonFactory, credential) + .setApplicationName(applicationName) + .build() + true + } else { + false + } + } catch (e: Exception) { + Logger.e("GoogleDriveClient", "Failed to authorize: ${e.message}") + // Ensure we don't keep a potentially inconsistent cached service + driveServiceCache = null + false + } + } + + override suspend fun signOut() = withContext(Dispatchers.IO) { + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val flow = getFlow(httpTransport) + flow.credentialDataStore.delete("user") + driveServiceCache = 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) { + Logger.e("GoogleDriveClient", "Failed to get user info: ${e.message}") + null + } + } +} + +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 new file mode 100644 index 0000000..c2f3a3d --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -0,0 +1,50 @@ +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.") + } + + 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() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1fee7ba..545a071 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,12 @@ 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-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" } androidLibrary = { id = "com.android.library", version.ref = "agp" }