Gast commit!

This commit is contained in:
2026-02-10 19:01:19 +08:00
commit 345f628a8a
3074 changed files with 278037 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+105
View File
@@ -0,0 +1,105 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.dev.googledrive"
compileSdk = 30
defaultConfig {
minSdk = 24
}
lint {
targetSdk = rootProject.extra["targetSdk"] as Int
}
testOptions {
targetSdk = rootProject.extra["targetSdk"] as Int
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += setOf(
"META-INF/DEPENDENCIES",
"META-INF/NOTICE",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/NOTICE.txt",
"META-INF/INDEX.LIST",
"META-INF/io.netty.versions.properties"
)
pickFirsts += setOf(
"google/protobuf/*.proto",
"META-INF/services/io.grpc.LoadBalancerProvider",
"META-INF/services/io.grpc.ManagedChannelProvider",
"META-INF/services/io.grpc.NameResolverProvider"
)
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.07.00") // Check for the latest BOM version
implementation(composeBom)
api("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.2")
implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.fragment:fragment-ktx:1.8.8")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("io.coil-kt.coil3:coil-compose:3.3.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0")
implementation("com.squareup.okhttp3:okhttp:5.1.0")
api(platform("com.google.firebase:firebase-bom:34.0.0"))
api("com.google.firebase:firebase-auth")
api("com.google.firebase:firebase-firestore")
api("com.google.firebase:firebase-storage")//optional to remove
//gRPC dependencies for Firestore
api("io.grpc:grpc-okhttp:1.74.0")
api("io.grpc:grpc-android:1.74.0")
api("io.grpc:grpc-protobuf-lite:1.74.0")
api("io.grpc:grpc-stub:1.74.0")
api("com.google.android.gms:play-services-auth:21.4.0")
api("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0")
api("com.google.api-client:google-api-client-android:2.8.0")
api("com.google.oauth-client:google-oauth-client-jetty:1.39.0")
api("com.google.http-client:google-http-client-gson:1.47.1")
api("androidx.credentials:credentials:1.5.0")
api("androidx.credentials:credentials-play-services-auth:1.5.0")
api("com.google.android.libraries.identity.googleid:googleid:1.1.1")
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- This is a library module, no specific permissions or components needed here -->
</manifest>
@@ -0,0 +1,141 @@
package mobi.librera.lib.gdrive
import android.content.Context
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.compose.ui.platform.ComposeView
import androidx.core.graphics.createBitmap
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
object CoverGenerator {
private const val TAG = "CoverGenerator"
/**
* Generate a cover image from a PDF file and save it as PNG
* @param context Android context
* @param pdfFile The PDF file to generate cover from
* @param outputFileName The name for the output cover file (without extension)
* @return Uri of the generated cover file, or null if failed
*/
fun generateCoverFromPDF(
context: Context,
pdfFile: File,
outputFileName: String
): Uri? {
return try {
val coverBitmap = renderPdfFirstPage(pdfFile) ?: return null
saveBitmapAsPng(context, coverBitmap, outputFileName)
} catch (e: Exception) {
Log.e(TAG, "Failed to generate cover from PDF", e)
null
}
}
/**
* Render the first page of a PDF as a bitmap
*/
private fun renderPdfFirstPage(pdfFile: File): Bitmap? {
return try {
if (!pdfFile.exists()) {
Log.e(TAG, "PDF file does not exist: ${pdfFile.absolutePath}")
return null
}
val fileDescriptor =
ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
val pdfRenderer = PdfRenderer(fileDescriptor)
if (pdfRenderer.pageCount == 0) {
pdfRenderer.close()
fileDescriptor.close()
return null
}
val page = pdfRenderer.openPage(0)
// Calculate appropriate bitmap size (max width 300px for covers)
val maxWidth = 300
val aspectRatio = page.height.toFloat() / page.width.toFloat()
val bitmapWidth = maxWidth
val bitmapHeight = (maxWidth * aspectRatio).toInt()
val bitmap = createBitmap(bitmapWidth, bitmapHeight)
// Render the page to bitmap
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
page.close()
pdfRenderer.close()
fileDescriptor.close()
Log.d(TAG, "Successfully rendered PDF cover: ${bitmapWidth}x${bitmapHeight}")
bitmap
} catch (e: Exception) {
Log.e(TAG, "Error rendering PDF first page", e)
null
}
}
/**
* Save a bitmap as PNG file and return its Uri
*/
private fun saveBitmapAsPng(
context: Context,
bitmap: Bitmap,
fileName: String
): Uri? {
return try {
// Create covers directory in internal storage
val coversDir = File(context.filesDir, "covers")
if (!coversDir.exists()) {
coversDir.mkdirs()
}
val coverFile = File(coversDir, "$fileName.png")
// Save bitmap to file
FileOutputStream(coverFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
Log.d(TAG, "Cover saved to: ${coverFile.absolutePath}")
Uri.fromFile(coverFile)
} catch (e: IOException) {
Log.e(TAG, "Error saving bitmap as PNG", e)
null
}
}
/**
* Generate cover name from file name (same name + .png extension)
*/
fun generateCoverName(fileName: String): String {
return fileName
}
/**
* Clean up generated cover files (optional utility)
*/
fun cleanupCovers(context: Context) {
try {
val coversDir = File(context.filesDir, "covers")
if (coversDir.exists()) {
coversDir.listFiles()?.forEach { file ->
if (file.isFile && file.name.endsWith(".png")) {
file.delete()
Log.d(TAG, "Deleted cover file: ${file.name}")
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error cleaning up covers", e)
}
}
}
@@ -0,0 +1,49 @@
package mobi.librera.lib.gdrive
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
data class DriveFileItem(
val id: String,
val name: String,
val mimeType: String,
val size: Long,
val modifiedTime: Long,
val isFolder: Boolean,
val webViewLink: String?,
val parentId: String,
val coverLink: String?
) {
fun getFormattedSize(): String {
if (isFolder) return "Folder"
return when {
size < 1024 -> "$size B"
size < 1024 * 1024 -> "${size / 1024} KB"
size < 1024 * 1024 * 1024 -> "${size / (1024 * 1024)} MB"
else -> "${size / (1024 * 1024 * 1024)} GB"
}
}
fun getFormattedDate(): String {
val date = Date(modifiedTime)
val format = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
return format.format(date)
}
fun getFileTypeDescription(): String {
return when {
isFolder -> "Folder"
mimeType.startsWith("image/") -> "Image"
mimeType.startsWith("video/") -> "Video"
mimeType.startsWith("audio/") -> "Audio"
mimeType == "application/pdf" -> "PDF Document"
mimeType.contains("document") -> "Document"
mimeType.contains("spreadsheet") -> "Spreadsheet"
mimeType.contains("presentation") -> "Presentation"
mimeType.startsWith("text/") -> "Text File"
else -> "File"
}
}
}
@@ -0,0 +1,101 @@
package mobi.librera.lib.gdrive
import android.annotation.SuppressLint
import androidx.core.net.toUri
import com.google.firebase.Firebase
import com.google.firebase.auth.auth
import com.google.firebase.firestore.firestore
import com.google.firebase.storage.storage
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resumeWithException
val KEY_USERS = "users"
val KEY_BOOKS = "books"
data class BookState(
val bookPath: String,
val fileName: String,
val progress: Float,
)
object FirestoreBooksRepository {
@SuppressLint("StaticFieldLeak")
private val db = Firebase.firestore
private val collection = db.collection("book_state")
fun listenToNotes(onChange: (List<BookState>) -> Unit) {
Firebase.auth.currentUser?.let { user ->
db.collection(KEY_USERS).document(user.uid).collection(KEY_BOOKS)
.addSnapshotListener { snapshot, _ ->
if (snapshot != null && !snapshot.isEmpty) {
val books = snapshot.toObjects(BookState::class.java)
onChange(books)
} else {
onChange(emptyList())
}
}
}
}
suspend fun uploadImageToFirebase(
book: File, coverFile: File
): String = suspendCancellableCoroutine { continuation ->
val storageRef = Firebase.storage
val userUID = Firebase.auth.currentUser?.uid
val imageRef = storageRef.getReference("users/$userUID/${book.name}")
println("uploadImageToFirebase ${imageRef.path} $imageRef")
imageRef.putFile(coverFile.toUri()).addOnSuccessListener {
imageRef.downloadUrl.addOnSuccessListener { downloadUrl ->
continuation.resume(downloadUrl.toString(), onCancellation = { a, b, c -> {} })
//imaUrl(downloadUrl.toString())
println("uploadImageToFirebase onSuccess |$downloadUrl")
}
}.addOnFailureListener { e ->
println("uploadImageToFirebase onError ${e.message}")
continuation.resumeWithException(e)
}
}
fun syncBook(book: BookState) = runBlocking {
// if (book.bookPaths[App.DEVICE_ID].isNullOrEmpty()) {
// println("Sync Book skip")
// return@runBlocking
// }
// launch {
// if (book.imageUrl.isEmpty()) {
// val bookPath = book.bookPaths[App.DEVICE_ID]
// if (!bookPath.isNullOrEmpty()) {
// val coverFile = File(bookPath)
// println("uploadImageToFirebase coverFile ${coverFile.isFile}")
// if (coverFile.isFile) {
// println("uploadImageToFirebase 2")
//
// book.imageUrl = uploadImageToFirebase(coverFile)
// println("Sync image url $book.imageUrl")
//
//
// }
// }
// }
println("Sync Book $book")
Firebase.auth.currentUser?.let { user ->
db.collection(KEY_USERS).document(user.uid).collection(KEY_BOOKS)
.document(book.fileName).set(book).addOnSuccessListener { documentReference ->
println("Sync success")
}.addOnFailureListener { e ->
println("Sync fail ${e.printStackTrace()}")
}
}
}
}
@@ -0,0 +1,360 @@
package mobi.librera.lib.gdrive
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.VideoFile
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.coroutines.launch
@Composable
fun GoogleDriveBrowser(
googleDriveHelper: GoogleDriveHelper,
isSignedIn: Boolean,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val driveFiles = remember { mutableStateOf<List<DriveFileItem>>(emptyList()) }
val currentFolderId = remember { mutableStateOf("root") }
val folderStack =
remember { mutableStateOf<List<Pair<String, String>>>(listOf("Drive" to "root")) }
val isLoading = remember { mutableStateOf(false) }
val errorMessage = remember { mutableStateOf<String?>(null) }
fun loadFiles(folderId: String = currentFolderId.value) {
if (!isSignedIn) {
Toast.makeText(context, "Please sign in to Google first", Toast.LENGTH_SHORT).show()
return
}
scope.launch {
isLoading.value = true
errorMessage.value = null
try {
val result = googleDriveHelper.listFilesWithCovers(folderId)
if (result.isSuccess) {
driveFiles.value = result.getOrNull() ?: emptyList()
} else {
errorMessage.value = result.exceptionOrNull()?.message ?: "Failed to load files"
Toast.makeText(context, errorMessage.value, Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
errorMessage.value = e.message ?: "Unknown error occurred"
Toast.makeText(context, errorMessage.value, Toast.LENGTH_LONG).show()
} finally {
isLoading.value = false
}
}
}
fun navigateToFolder(folderId: String, folderName: String) {
currentFolderId.value = folderId
folderStack.value += (folderName to folderId)
loadFiles(folderId)
}
fun navigateBack() {
if (folderStack.value.size > 1) {
val newStack = folderStack.value.dropLast(1)
folderStack.value = newStack
currentFolderId.value = newStack.last().second
loadFiles(newStack.last().second)
}
}
// Load files when signed in status changes
LaunchedEffect(isSignedIn) {
if (isSignedIn) {
loadFiles()
} else {
driveFiles.value = emptyList()
currentFolderId.value = "root"
folderStack.value = listOf("Drive" to "root")
}
}
Column(modifier = modifier) {
// Navigation breadcrumb
if (isSignedIn && folderStack.value.isNotEmpty()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (folderStack.value.size > 1) {
IconButton(
onClick = { navigateBack() },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Go back",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = folderStack.value.joinToString(" > ") { it.first },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { loadFiles() },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
// Content area
Box(modifier = Modifier.weight(1f)) {
when {
!isSignedIn -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.CloudOff,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Sign in to Google to browse your Drive",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
isLoading.value -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Loading files...",
style = MaterialTheme.typography.bodyMedium
)
}
}
driveFiles.value.isEmpty() && errorMessage.value == null -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.FolderOpen,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "This folder is empty",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn {
items(driveFiles.value) { file ->
DriveFileItemCard(
file = file,
onFolderClick = { navigateToFolder(file.id, file.name) },
onFileClick = {
Toast.makeText(
context,
"File: ${file.name}",
Toast.LENGTH_SHORT
).show()
// Here you could add download functionality
}
)
}
}
}
}
}
}
}
@Composable
fun DriveFileItemCard(
file: DriveFileItem,
onFolderClick: () -> Unit,
onFileClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable {
if (file.isFolder) {
onFolderClick()
} else {
onFileClick()
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// File/Folder icon
if (!file.isFolder && file.coverLink != null) {
AsyncImage(
model = file.coverLink,
contentDescription = "Cover Image",
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
)
} else {
Icon(
imageVector = when {
file.isFolder -> Icons.Default.Folder
file.mimeType.startsWith("image/") -> Icons.Default.Image
file.mimeType.startsWith("video/") -> Icons.Default.VideoFile
file.mimeType.startsWith("audio/") -> Icons.Default.AudioFile
file.mimeType == "application/pdf" -> Icons.Default.PictureAsPdf
file.mimeType.contains("document") -> Icons.Default.Description
else -> Icons.Default.InsertDriveFile
},
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = when {
file.isFolder -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
Spacer(modifier = Modifier.width(16.dp))
// File info
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = file.getFileTypeDescription(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (!file.isFolder) {
Text(
text = file.getFormattedSize(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = file.getFormattedDate(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Navigate arrow for folders
if (file.isFolder) {
Icon(
Icons.Default.ChevronRight,
contentDescription = "Enter folder",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,532 @@
package mobi.librera.lib.gdrive
import android.content.Context
import android.net.Uri
import android.util.Log
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.Scope
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.InputStream
class GoogleDriveHelper(private val context: Context) {
companion object {
private const val TAG = "GoogleDriveHelper"
private val SCOPES = listOf(DriveScopes.DRIVE_FILE)
}
private var driveService: Drive? = null
/**
* Get Google Sign-In options
*/
fun getGoogleSignInOptions(): GoogleSignInOptions {
return GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(Scope(DriveScopes.DRIVE_FILE))
.build()
}
/**
* Initialize Drive service with signed-in account
*/
fun initializeDriveService(account: GoogleSignInAccount?) {
if (account == null) {
Log.e(TAG, "Google account is null")
return
}
try {
val credential = GoogleAccountCredential.usingOAuth2(context, SCOPES)
credential.selectedAccount = account.account
driveService = Drive.Builder(
NetHttpTransport(),
GsonFactory.getDefaultInstance(),
credential
)
.setApplicationName("FileViewerApp")
.build()
Log.d(TAG, "Drive service initialized successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize Drive service", e)
}
}
/**
* Check if user is signed in to Google
*/
@Suppress("DEPRECATION")
fun isSignedIn(): Boolean {
val account = GoogleSignIn.getLastSignedInAccount(context)
return account != null && driveService != null
}
/**
* Sign out from Google account
*/
@Suppress("DEPRECATION")
suspend fun signOut(): Result<Unit> = withContext(Dispatchers.IO) {
try {
val signInClient = GoogleSignIn.getClient(context, getGoogleSignInOptions())
signInClient.signOut()
driveService = null
Log.d(TAG, "Successfully signed out from Google account")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to sign out", e)
Result.failure(e)
}
}
/**
* Upload file to Google Drive
*/
suspend fun uploadFile(
fileUri: Uri,
fileName: String,
mimeType: String
): Result<String> = uploadFileToFolder(fileUri, fileName, mimeType, "root")
/**
* Upload file to specific folder in Google Drive
*/
suspend fun uploadFileToFolder(
fileUri: Uri,
fileName: String,
mimeType: String,
parentFolderId: String = "root"
): Result<String> = withContext(Dispatchers.IO) {
try {
val service = driveService ?: return@withContext Result.failure(
Exception("Drive service not initialized. Please sign in first.")
)
Log.d(TAG, "Starting upload for file: $fileName")
// Read file content
val inputStream = context.contentResolver.openInputStream(fileUri)
?: return@withContext Result.failure(Exception("Could not open file"))
val fileContent = inputStream.readBytes()
inputStream.close()
// Create file metadata
val fileMetadata = File().apply {
name = fileName
parents = listOf(parentFolderId)
}
// Create media content
val mediaContent = com.google.api.client.http.InputStreamContent(
mimeType,
java.io.ByteArrayInputStream(fileContent)
)
mediaContent.length = fileContent.size.toLong()
// Upload the file
val uploadedFile = service.files()
.create(fileMetadata, mediaContent)
.setFields("id, name, size, mimeType")
.execute()
Log.d(TAG, "File uploaded successfully: ${uploadedFile.name} (ID: ${uploadedFile.id})")
Result.success(uploadedFile.id)
} catch (e: Exception) {
Log.e(TAG, "Failed to upload file", e)
Result.failure(e)
}
}
/**
* Get upload progress (placeholder for future implementation)
*/
suspend fun uploadFileWithProgress(
fileUri: Uri,
fileName: String,
mimeType: String,
onProgress: (Int) -> Unit
): Result<String> {
// For now, just call regular upload
// In a real implementation, you would track upload progress
onProgress(0)
val result = uploadFile(fileUri, fileName, mimeType)
onProgress(100)
return result
}
/**
* List files and folders in Google Drive
*/
suspend fun listFiles(
folderId: String = "root",
pageSize: Int = 50
): Result<List<DriveFileItem>> = withContext(Dispatchers.IO) {
try {
val service = driveService ?: return@withContext Result.failure(
Exception("Drive service not initialized. Please sign in first.")
)
Log.d(TAG, "Listing files in folder: $folderId")
val query = "'$folderId' in parents and trashed=false"
val result = service.files()
.list()
.setQ(query)
.setPageSize(pageSize)
.setFields("files(id,name,mimeType,size,modifiedTime,parents,webViewLink,thumbnailLink)")
.execute()
val driveFiles = result.files?.map { file ->
DriveFileItem(
id = file.id,
name = file.name,
mimeType = file.mimeType,
size = file.size.toLong() ?: 0L,
modifiedTime = file.modifiedTime?.value ?: 0L,
isFolder = file.mimeType == "application/vnd.google-apps.folder",
webViewLink = file.webViewLink,
parentId = folderId,
coverLink = file.thumbnailLink // Use Google Drive's built-in thumbnail
)
} ?: emptyList()
Log.d(TAG, "Found ${driveFiles.size} files in folder $folderId")
Result.success(driveFiles)
} catch (e: Exception) {
Log.e(TAG, "Failed to list files", e)
Result.failure(e)
}
}
/**
* Find or create Books folder in Google Drive
*/
suspend fun findOrCreateBooksFolder(): Result<String> = withContext(Dispatchers.IO) {
try {
val service = driveService ?: return@withContext Result.failure(
Exception("Drive service not initialized. Please sign in first.")
)
Log.d(TAG, "Looking for Books folder...")
// Search for existing Books folder
val query =
"name='Books' and mimeType='application/vnd.google-apps.folder' and trashed=false"
val searchResult = service.files()
.list()
.setQ(query)
.setFields("files(id,name)")
.execute()
val existingFolder = searchResult.files?.firstOrNull()
if (existingFolder != null) {
Log.d(TAG, "Found existing Books folder: ${existingFolder.id}")
return@withContext Result.success(existingFolder.id)
}
// Create Books folder if it doesn't exist
Log.d(TAG, "Creating new Books folder...")
val folderMetadata = File().apply {
name = "Books"
mimeType = "application/vnd.google-apps.folder"
parents = listOf("root")
}
val createdFolder = service.files()
.create(folderMetadata)
.setFields("id,name")
.execute()
Log.d(TAG, "Created Books folder: ${createdFolder.id}")
Result.success(createdFolder.id)
} catch (e: Exception) {
Log.e(TAG, "Failed to find or create Books folder", e)
Result.failure(e)
}
}
/**
* Upload file to Books folder in Google Drive with progress tracking
*/
suspend fun uploadFileToBooks(
fileUri: Uri,
fileName: String,
mimeType: String,
onProgress: ((Int) -> Unit)? = null
): Result<String> = withContext(Dispatchers.IO) {
try {
onProgress?.invoke(10) // Finding/creating Books folder
val booksFolderResult = findOrCreateBooksFolder()
if (booksFolderResult.isFailure) {
return@withContext Result.failure(
booksFolderResult.exceptionOrNull()
?: Exception("Failed to access Books folder")
)
}
onProgress?.invoke(30) // Starting book upload
val booksFolderId = booksFolderResult.getOrThrow()
val uploadResult = uploadFileToFolderWithProgress(
fileUri,
fileName,
mimeType,
booksFolderId
) { progress ->
// Map upload progress to 30-80% range
onProgress?.invoke(30 + (progress * 50 / 100))
}
if (uploadResult.isSuccess) {
onProgress?.invoke(100) // Upload complete
}
return@withContext uploadResult
} catch (e: Exception) {
Log.e(TAG, "Failed to upload file to Books folder", e)
Result.failure(e)
}
}
/**
* Upload file to specific folder in Google Drive with progress tracking
*/
suspend fun uploadFileToFolderWithProgress(
fileUri: Uri,
fileName: String,
mimeType: String,
parentFolderId: String = "root",
onProgress: ((Int) -> Unit)? = null
): Result<String> = withContext(Dispatchers.IO) {
try {
onProgress?.invoke(0)
val result = uploadFileToFolder(fileUri, fileName, mimeType, parentFolderId)
onProgress?.invoke(100)
result
} catch (e: Exception) {
Log.e(TAG, "Failed to upload file with progress", e)
Result.failure(e)
}
}
/**
* Upload cover to Covers folder in Google Drive
*/
suspend fun uploadCoverToCovers(
coverUri: Uri,
coverName: String
): Result<String> {
return withContext(Dispatchers.IO) {
try {
val coversFolderResult = findOrCreateCoversFolder()
if (coversFolderResult.isFailure) {
return@withContext Result.failure(
coversFolderResult.exceptionOrNull()
?: Exception("Failed to access Covers folder")
)
}
val coversFolderId = coversFolderResult.getOrThrow()
uploadFileToFolder(coverUri, coverName, "image/png", coversFolderId)
} catch (e: Exception) {
Log.e(TAG, "Failed to upload cover to Covers folder", e)
Result.failure(e)
}
}
}
/**
* Find or create Covers folder in Google Drive
*/
suspend fun findOrCreateCoversFolder(): Result<String> = withContext(Dispatchers.IO) {
try {
val service = driveService ?: return@withContext Result.failure(
Exception("Drive service not initialized. Please sign in first.")
)
Log.d(TAG, "Looking for Covers folder...")
// Search for existing Covers folder
val query =
"name='Covers' and mimeType='application/vnd.google-apps.folder' and trashed=false"
val searchResult = service.files()
.list()
.setQ(query)
.setFields("files(id,name)")
.execute()
val existingFolder = searchResult.files?.firstOrNull()
if (existingFolder != null) {
Log.d(TAG, "Found existing Covers folder: ${existingFolder.id}")
return@withContext Result.success(existingFolder.id)
}
// Create Covers folder if it doesn't exist
Log.d(TAG, "Creating new Covers folder...")
val folderMetadata = File().apply {
name = "Covers"
mimeType = "application/vnd.google-apps.folder"
parents = listOf("root")
}
val createdFolder = service.files()
.create(folderMetadata)
.setFields("id,name")
.execute()
Log.d(TAG, "Created Covers folder: ${createdFolder.id}")
Result.success(createdFolder.id)
} catch (e: Exception) {
Log.e(TAG, "Failed to find or create Covers folder", e)
Result.failure(e)
}
}
/**
* Get the cover URL for a given file (typically a book)
*/
suspend fun getCoverUrlForFile(fileName: String): String? = withContext(Dispatchers.IO) {
try {
val service = driveService ?: return@withContext null
// Find the Covers folder
val coversFolderResult = findOrCreateCoversFolder()
if (coversFolderResult.isFailure) {
return@withContext null
}
val coversFolderId = coversFolderResult.getOrThrow()
// Generate the cover name from the file name
val coverName = CoverGenerator.generateCoverName(fileName) + ".png"
// Search for the cover file in Covers folder
val query = "name='$coverName' and '$coversFolderId' in parents and trashed=false"
val result = service.files()
.list()
.setQ(query)
.setFields("files(id,thumbnailLink)")
.execute()
val coverFile = result.files?.firstOrNull()
if (coverFile != null) {
Log.d(TAG, "Found cover for $fileName: ${coverFile.id}")
// Use thumbnailLink for image files - it's a direct URL that works well with image loaders
val thumbnailUrl = coverFile.thumbnailLink
if (thumbnailUrl != null) {
Log.d(TAG, "Cover thumbnail URL: $thumbnailUrl")
return@withContext thumbnailUrl
}
}
null
} catch (e: Exception) {
Log.e(TAG, "Failed to get cover URL for file: $fileName", e)
null
}
}
/**
* List files with covers (for Books folder)
*/
suspend fun listFilesWithCovers(
folderId: String = "root",
pageSize: Int = 50
): Result<List<DriveFileItem>> = withContext(Dispatchers.IO) {
try {
// First, get the regular file list
val filesResult = listFiles(folderId, pageSize)
if (filesResult.isFailure) {
return@withContext filesResult
}
val files = filesResult.getOrThrow()
// Check if we're in the Books folder
val isInBooksFolder = if (folderId != "root") {
try {
val service = driveService ?: return@withContext Result.success(files)
val folderResult = service.files().get(folderId).setFields("name").execute()
folderResult.name == "Books"
} catch (e: Exception) {
Log.e(TAG, "Failed to check if in Books folder", e)
false
}
} else {
false
}
if (!isInBooksFolder) {
// If not in Books folder, return files as-is
return@withContext Result.success(files)
}
// For each PDF file in the Books folder, try to find its cover
val filesWithCovers = files.map { file ->
if (!file.isFolder && file.mimeType == "application/pdf") {
val coverUrl = getCoverUrlForFile(file.name)
file.copy(coverLink = coverUrl)
} else {
file
}
}
Result.success(filesWithCovers)
} catch (e: Exception) {
Log.e(TAG, "Failed to list files with covers", e)
Result.failure(e)
}
}
suspend fun downloadFile(
fileId: String,
fileName: String
): Result<ByteArray> = withContext(Dispatchers.IO) {
try {
val service = driveService ?: return@withContext Result.failure(
Exception("Drive service not initialized. Please sign in first.")
)
Log.d(TAG, "Downloading file: $fileName (ID: $fileId)")
val outputStream = ByteArrayOutputStream()
service.files().get(fileId).executeMediaAndDownloadTo(outputStream)
val fileContent = outputStream.toByteArray()
Log.d(TAG, "Downloaded file: $fileName, size: ${fileContent.size} bytes")
Result.success(fileContent)
} catch (e: Exception) {
Log.e(TAG, "Failed to download file: $fileName", e)
Result.failure(e)
}
}
}
// Extension function to read bytes from InputStream
private fun InputStream.readBytes(): ByteArray {
val buffer = ByteArrayOutputStream()
val data = ByteArray(1024)
var nRead: Int
while (read(data, 0, data.size).also { nRead = it } != -1) {
buffer.write(data, 0, nRead)
}
return buffer.toByteArray()
}
@@ -0,0 +1,51 @@
package mobi.librera.lib.gdrive
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.dev.googledrive.R
@Composable
fun GoogleSignInButton(
text: String,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
modifier = Modifier.padding(4.dp),
elevation = ButtonDefaults.elevatedButtonElevation(defaultElevation = 3.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painterResource(R.drawable.google_icon),
modifier = Modifier
.size(30.dp)
.padding(4.dp),
contentDescription = ""
)
Spacer(Modifier.width(10.dp))
Text(text)
}
}
}
@@ -0,0 +1,17 @@
package mobi.librera.lib.gdrive
import androidx.compose.ui.platform.ComposeView
object GoogleSignInComposeHelper {
@JvmStatic
fun createSimpleGoogleSignInButton(
composeView: ComposeView, clientId: String
) {
composeView.setContent {
GoogleSignInScreen(
clientId
)
}
}
}
@@ -0,0 +1,189 @@
package mobi.librera.lib.gdrive
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.common.api.ApiException
import kotlinx.coroutines.launch
@Composable
fun GDriveButton(
googleDriveHelper: GoogleDriveHelper,
isSignedIn: Boolean,
onSingIn: () -> Unit,
onSingOut: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val googleSignInClient =
remember { GoogleSignIn.getClient(context, googleDriveHelper.getGoogleSignInOptions()) }
// Google Sign-In launcher for OAuth authorization
val googleSignInLauncher =
rememberLauncherForActivityResult(StartActivityForResult()) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try {
val account = task.getResult(ApiException::class.java)
googleDriveHelper.initializeDriveService(account)
onSingIn()
Toast.makeText(context, "Signed in as ${account.email}", Toast.LENGTH_SHORT).show()
Log.d("MainActivity", "Signed in as: ${account.email}")
} catch (e: ApiException) {
Log.e("MainActivity", "Google sign-in failed", e)
Toast.makeText(
context,
"Drive authorization failed: ${e.message}",
Toast.LENGTH_SHORT
).show()
}
}
if (!isSignedIn) {
Button(
modifier = Modifier.padding(start = 12.dp),
onClick = {
val signInIntent = googleSignInClient.signInIntent
googleSignInLauncher.launch(signInIntent)
}
) {
Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Connect Google Drive")
}
} else {
Button(
modifier = Modifier.padding(start = 12.dp),
onClick = {
scope.launch {
val result = googleDriveHelper.signOut()
if (result.isSuccess) {
onSingOut()
Toast.makeText(context, "Signed out successfully", Toast.LENGTH_SHORT)
.show()
} else {
Toast.makeText(
context,
"Failed to sign out: ${result.exceptionOrNull()?.message}",
Toast.LENGTH_SHORT
).show()
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Disconnect Google Drive")
}
}
}
@Composable
fun GoogleSignInScreen(
clientId: String
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val viewModel: GoogleSingInViewModel = viewModel()
val singInState by viewModel.singInState.collectAsState()
var isSignedIn by remember { mutableStateOf(false) }
val googleDriveHelper = remember { GoogleDriveHelper(context) }
LaunchedEffect(Unit) {
viewModel.checkSingInState()
}
when (val state = singInState) {
is SingInState.NotSignIn -> {
GoogleSignInButton(
"Sing in with Google", onClick = {
scope.launch {
viewModel.signInWithGoogle(context, clientId)
}
})
}
is SingInState.Success -> {
Column(Modifier.padding(4.dp)) {
Row(
modifier = Modifier.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
AsyncImage(
model = state.user.photoUrl,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
contentDescription = state.user.name
)
Column(Modifier.padding(start = 12.dp)) {
Text(state.user.name)
Text(state.user.email)
}
GoogleSignInButton(
"Sing out", onClick = {
scope.launch {
viewModel.signOut(context)
}
})
}
GDriveButton(
googleDriveHelper, isSignedIn,
onSingIn = { isSignedIn = true }, onSingOut = { isSignedIn = false })
GoogleDriveBrowser(googleDriveHelper, isSignedIn)
}
}
is SingInState.Error -> {
Text("Error: ${state.message}")
}
}
}
@@ -0,0 +1,162 @@
package mobi.librera.lib.gdrive
import android.content.Context
import androidx.credentials.ClearCredentialStateRequest
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialException
import androidx.lifecycle.ViewModel
import coil3.Uri
import coil3.toCoilUri
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import com.google.firebase.Firebase
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.GoogleAuthProvider
import com.google.firebase.auth.auth
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
data class User(val name: String, val email: String, val photoUrl: Uri?)
fun FirebaseUser?.toUser(): User = User(
this?.displayName.orEmpty(), this?.email.orEmpty(), this?.photoUrl?.toCoilUri()
)
sealed class SingInState {
data object NotSignIn : SingInState()
data class Success(val user: User) : SingInState()
data class Error(val message: String) : SingInState()
}
// Simplified version without BookRepository dependency to avoid circular dependency
class GoogleSingInViewModel() : ViewModel() {
private val _state = MutableStateFlow<SingInState>(SingInState.NotSignIn)
val singInState: StateFlow<SingInState> = _state
private fun observeFirestoreAndSyncToRoom() {
// TODO: This functionality should be moved to the app module where BookRepository is available
// For now, we'll just skip the sync functionality
println("Sync: Skipped due to circular dependency")
}
init {
// Skip initialization of sync for now
// observeFirestoreAndSyncToRoom()
}
fun checkSingInState() {
val auth = Firebase.auth
val currentUser = auth.currentUser
if (currentUser == null) {
_state.value = SingInState.NotSignIn
} else {
_state.value = SingInState.Success(currentUser.toUser())
}
}
suspend fun signInWithGoogle(context: Context, clientId: String) {
val credentialManager = CredentialManager.create(context)
val googleIdOption: GetGoogleIdOption =
GetGoogleIdOption.Builder().setFilterByAuthorizedAccounts(false)
.setServerClientId(clientId).build()
val request: GetCredentialRequest =
GetCredentialRequest.Builder().addCredentialOption(googleIdOption).build()
coroutineScope {
try {
val result = credentialManager.getCredential(
request = request,
context = context,
)
handleSignInWithGoogleOption(result)
} catch (e: GetCredentialException) {
e.printStackTrace()
_state.value = SingInState.Error(e.message.orEmpty())
}
}
}
private fun handleSignInWithGoogleOption(result: GetCredentialResponse) {
when (val credential = result.credential) {
is CustomCredential -> {
if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
try {
val googleIdTokenCredential =
GoogleIdTokenCredential.createFrom(credential.data)
firebaseAuthWithGoogle(googleIdTokenCredential.idToken)
} catch (e: GoogleIdTokenParsingException) {
e.printStackTrace()
_state.value = SingInState.Error(e.message.orEmpty())
}
} else {
_state.value = SingInState.Error("Unexpected type of credential")
}
}
else -> {
_state.value = SingInState.Error("Unexpected Error")
}
}
}
private fun firebaseAuthWithGoogle(idToken: String) {
val credential = GoogleAuthProvider.getCredential(idToken, null)
val auth = Firebase.auth
auth.signInWithCredential(credential).addOnCompleteListener { task ->
if (task.isSuccessful) {
val user = auth.currentUser
println("current user $user")
_state.value = SingInState.Success(user.toUser())
observeFirestoreAndSyncToRoom()
} else {
_state.value = SingInState.Error(task.exception?.message.orEmpty())
}
}
}
fun gdirveSingIn(context: Context) {
}
suspend fun signOut(context: Context) {
val auth = Firebase.auth
val credentialManager = CredentialManager.create(context)
val clearRequest = ClearCredentialStateRequest()
coroutineScope {
credentialManager.clearCredentialState(clearRequest)
}
auth.signOut()
_state.value = SingInState.NotSignIn
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportHeight="48"
android:viewportWidth="48">
<group>
<path
android:fillColor="#EA4335"
android:pathData="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
<path
android:fillColor="#4285F4"
android:pathData="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
<path
android:fillColor="#FBBC05"
android:pathData="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
<path
android:fillColor="#34A853"
android:pathData="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</group>
</vector>