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..2cfba71 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -12,7 +12,7 @@ android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:name="io.github.smiling_pixel.MainActivity"> 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..25e0ddb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -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 +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) + 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 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..dcc8170 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/client/GoogleSignInHelper.kt @@ -0,0 +1,30 @@ +package io.github.smiling_pixel.client + +import android.app.Activity +import android.content.Intent +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import kotlinx.coroutines.CompletableDeferred + +object GoogleSignInHelper { + private var launcher: ActivityResultLauncher? = null + private var authDeferred: CompletableDeferred? = null + + fun registerLauncher(launcher: ActivityResultLauncher) { + this.launcher = launcher + } + + fun onActivityResult(result: ActivityResult) { + authDeferred?.complete(result) + authDeferred = null + } + + suspend fun launchSignIn(intent: Intent): ActivityResult? { + val l = launcher ?: return null + val deferred = CompletableDeferred() + authDeferred = deferred + l.launch(intent) + return deferred.await() + } +} diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt index 011c69e..c6a5393 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/SettingsScreen.kt @@ -14,10 +14,17 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope +import io.github.smiling_pixel.client.UserInfo +import io.github.smiling_pixel.client.getCloudDriveClient import io.github.smiling_pixel.preference.getSettingsRepository import kotlinx.coroutines.launch @@ -28,6 +35,17 @@ fun SettingsScreen() { val apiKey by settingsRepository.googleWeatherApiKey.collectAsState(initial = null) val uriHandler = LocalUriHandler.current + val cloudDriveClient = remember { getCloudDriveClient() } + var userInfo by remember { mutableStateOf(null) } + var isAuthorized by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + isAuthorized = cloudDriveClient.isAuthorized() + if (isAuthorized) { + userInfo = cloudDriveClient.getUserInfo() + } + } + Column( modifier = Modifier .fillMaxSize() @@ -56,6 +74,40 @@ fun SettingsScreen() { singleLine = true ) + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Cloud Drive Sync", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + + if (isAuthorized) { + Text("Signed in as: ${userInfo?.name ?: "Loading..."}") + Text("Email: ${userInfo?.email ?: ""}") + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + scope.launch { + cloudDriveClient.signOut() + isAuthorized = false + userInfo = null + } + }) { + Text("Revoke Authorization") + } + } else { + Button(onClick = { + scope.launch { + if (cloudDriveClient.authorize()) { + isAuthorized = true + userInfo = cloudDriveClient.getUserInfo() + } + } + }) { + Text("Authorize Google Drive") + } + } + Spacer(modifier = Modifier.height(24.dp)) Text( diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt new file mode 100644 index 0000000..2b7f7d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/client/CloudDriveClient.kt @@ -0,0 +1,76 @@ +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 + + suspend fun isAuthorized(): Boolean + suspend fun authorize(): Boolean + suspend fun signOut() + suspend fun getUserInfo(): UserInfo? +} + +expect fun getCloudDriveClient(): CloudDriveClient + diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt new file mode 100644 index 0000000..b43fde5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt @@ -0,0 +1,247 @@ +package io.github.smiling_pixel.client + +import com.google.api.client.auth.oauth2.Credential +import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp +import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow +import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.client.util.store.FileDataStoreFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.InputStreamReader +import java.util.Collections +import java.io.File as JavaFile + +/** + * Implementation of [CloudDriveClient] for Google Drive on JVM. + * Uses the official Google Drive Java client library. + * + * References: + * https://developers.google.com/workspace/drive/api/quickstart/java + * https://developers.google.com/workspace/drive/api/guides/search-files + * https://developers.google.com/workspace/drive/api/guides/manage-files + */ +class GoogleDriveClient : CloudDriveClient { + + private val jsonFactory = GsonFactory.getDefaultInstance() + private val applicationName = "MarkDay Diary" + + /** + * Directory to store authorization tokens for this application. + */ + private val TOKENS_DIRECTORY_PATH = "tokens" + + /** + * Global instance of the scopes required by this quickstart. + * If modifying these scopes, delete your previously saved tokens/ folder. + */ + private val SCOPES = listOf(DriveScopes.DRIVE_FILE) + private val CREDENTIALS_FILE_PATH = "/credentials.json" + + private fun getFlow(httpTransport: NetHttpTransport): GoogleAuthorizationCodeFlow { + val inputStream = GoogleDriveClient::class.java.getResourceAsStream(CREDENTIALS_FILE_PATH) + ?: throw FileNotFoundException("Resource not found: $CREDENTIALS_FILE_PATH. Please obtain credentials.json from Google Cloud Console.") + + val clientSecrets = GoogleClientSecrets.load(jsonFactory, InputStreamReader(inputStream)) + + 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 _driveService: Drive? = null + + private fun getDriveService(): Drive { + if (_driveService == null) { + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val credential = getCredentials(httpTransport) + _driveService = Drive.Builder(httpTransport, jsonFactory, credential) + .setApplicationName(applicationName) + .build() + } + return _driveService!! + } + + override suspend fun listFiles(parentId: String?): List = withContext(Dispatchers.IO) { + val folderId = parentId ?: "root" + val query = "'$folderId' in parents and trashed = false" + + val result = 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) { + e.printStackTrace() + false + } + } + + override suspend fun authorize(): Boolean = withContext(Dispatchers.IO) { + try { + // Force re-authorization or load existing + // accessing driveService triggers authorization via getCredentials + // But getCredentials calls `authorize("user")` + // If we are already authorized, this returns immediately. + // If not, it opens browser. + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val credential = getCredentials(httpTransport) + credential != null + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + override suspend fun signOut() = withContext(Dispatchers.IO) { + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val flow = getFlow(httpTransport) + flow.credentialDataStore.delete("user") + _driveService = null + } + + override suspend fun getUserInfo(): UserInfo? = withContext(Dispatchers.IO) { + if (!isAuthorized()) return@withContext null + try { + val about = getDriveService().about().get().setFields("user").execute() + val user = about.user + UserInfo( + name = user.displayName, + email = user.emailAddress, + photoUrl = user.photoLink + ) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + companion object { + private const val MIME_TYPE_FOLDER = "application/vnd.google-apps.folder" + } +} + +private val googleDriveClientInstance by lazy { GoogleDriveClient() } +actual fun getCloudDriveClient(): CloudDriveClient = googleDriveClientInstance diff --git a/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/client/GoogleDriveClient.kt 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" }