Gast commit!
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user