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..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 new file mode 100644 index 0000000..eee362c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -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() + 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 = 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 \ 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..590b0b1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt @@ -0,0 +1,62 @@ +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 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? { + + // 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..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 @@ -14,10 +16,20 @@ 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.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 import kotlinx.coroutines.launch @@ -28,6 +40,34 @@ 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) } + + 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() @@ -56,6 +96,89 @@ 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 (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 { + 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 { + 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") + } + } + 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..fe21312 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt @@ -0,0 +1,106 @@ +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 { + /** + * 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..1d3b266 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -0,0 +1,272 @@ +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.util.Collections +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" + } + + 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) + + 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 = GoogleClientSecrets.load(jsonFactory, InputStreamReader(inputStream)) + + 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 var driveServiceCache: Drive? = null + + private fun getDriveService(): Drive { + if (driveServiceCache == null) { + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val credential = getCredentials(httpTransport) + driveServiceCache = Drive.Builder(httpTransport, jsonFactory, credential) + .setApplicationName(applicationName) + .build() + } + return driveServiceCache!! + } + + override suspend fun listFiles(parentId: String?): List = withContext(Dispatchers.IO) { + val folderId = parentId ?: "root" + val query = "'$folderId' 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 == 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 == 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 = getDriveService().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) { + 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 == 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) + credential != null + } catch (e: Exception) { + Logger.e("GoogleDriveClient", "Failed to authorize: ${e.message}") + 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 + } + } + + 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 new file mode 100644 index 0000000..5ee1196 --- /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" }