-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Google Drive support #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bc4f187
a68664d
2061490
861c4ea
19ab7a7
16d603a
0bff97a
2d5c9e6
d3e207b
cc0f444
1154524
558ff32
b9a9eed
09bba31
dee28c0
c4b4515
068832f
7e4fa9f
b20751f
ee0cf29
d9076ce
f8eeea5
d79b504
8f77cbd
068ca1e
c099054
791f657
6c7a840
8f9a2e0
48284d7
1467aa5
f46714e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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() | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+45
to
+49
|
||||||||||||
| override fun onDestroy() { | |
| super.onDestroy() | |
| GoogleSignInHelper.unregisterLauncher() | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 | ||||||||||||||||||||||||||||||||||
SmilingPixel marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
| 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() | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
SmilingPixel marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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<Unit>() | ||||||||||||||||||||||||||||||||||
| 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<DriveFile> = withContext(Dispatchers.IO) { | ||||||||||||||||||||||||||||||||||
| val folderId = parentId ?: "root" | ||||||||||||||||||||||||||||||||||
| // Sanitize folderId to prevent injection | ||||||||||||||||||||||||||||||||||
| val sanitizedFolderId = folderId.replace("'", "\\'") | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+161
to
+162
|
||||||||||||||||||||||||||||||||||
| // Sanitize folderId to prevent injection | |
| val sanitizedFolderId = folderId.replace("'", "\\'") | |
| // Sanitize folderId to prevent injection by escaping backslashes and single quotes | |
| val sanitizedFolderId = folderId | |
| .replace("\\", "\\\\") | |
| .replace("'", "\\'") |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The sanitization approach using replace("'", "\\'") is insufficient and potentially incorrect for Google Drive API queries. The backslash itself may need escaping depending on the context. Additionally, this doesn't protect against other special characters. The Google Drive API query language has its own escaping rules. Consider using proper query building methods or ensuring the escaping matches Google's documented requirements.
Copilot
AI
Feb 5, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The updateFile method makes two sequential API calls - first to retrieve the existing mimeType, then to update the file. This doubles the network overhead and latency. Consider one of these alternatives: (1) pass mimeType as a parameter to updateFile, (2) use a generic mime type like "application/octet-stream" without fetching, or (3) add an optional parameter to specify whether to preserve the existing mime type. The current approach impacts performance unnecessarily.
| 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() | |
| val fileMetadata = File() | |
| val mediaContent = ByteArrayContent("application/octet-stream", content) | |
| val updatedFile = getService().files().update(fileId, fileMetadata, mediaContent) | |
| .setFields("id, name, mimeType") | |
| .execute() |
Uh oh!
There was an error while loading. Please reload this page.