feat: added google drive service.

Signed-off-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
KaiserBh 2023-08-03 03:50:01 +10:00
parent 536c5facb9
commit 645505e1e9
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
13 changed files with 525 additions and 5 deletions

View File

@ -257,6 +257,9 @@ dependencies {
// For detecting memory leaks; see https://square.github.io/leakcanary/
// debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber)
implementation(libs.google.api.services.drive)
implementation(libs.google.api.client.oauth)
}
androidComponents {

View File

@ -73,4 +73,10 @@
# Firebase
-keep class com.google.firebase.installations.** { *; }
-keep interface com.google.firebase.installations.** { *; }
-keep interface com.google.firebase.installations.** { *; }
# Google Drive
-keep class com.google.api.services.** { *; }
# Google OAuth
-keep class com.google.api.client.** { *; }

View File

@ -183,6 +183,20 @@
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.GoogleDriveLoginActivity"
android:label="GoogleDrive"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="eu.kanade.google.oauth" />
</intent-filter>
</activity>
<receiver
android:name=".data.notification.NotificationReceiver"

View File

@ -0,0 +1 @@
{"installed":{"client_id":"1046609911130-tbp79niehhuii976ekep1us06e9a8lne.apps.googleusercontent.com","project_id":"tachiyomi","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs"}}

View File

@ -52,6 +52,8 @@ import eu.kanade.tachiyomi.data.backup.BackupRestoreJob
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.data.sync.SyncManager
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard
@ -94,6 +96,7 @@ object SettingsBackupAndSyncScreen : SearchableSettings {
entries = mapOf(
SyncManager.SyncService.NONE.value to stringResource(R.string.off),
SyncManager.SyncService.SYNCYOMI.value to stringResource(R.string.syncyomi),
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(R.string.google_drive),
),
onValueChanged = { true },
),
@ -448,6 +451,7 @@ object SettingsBackupAndSyncScreen : SearchableSettings {
return when (syncServiceType) {
SyncManager.SyncService.NONE -> emptyList()
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences()
} +
if (syncServiceType == SyncManager.SyncService.NONE) {
emptyList()
@ -456,6 +460,74 @@ object SettingsBackupAndSyncScreen : SearchableSettings {
}
}
@Composable
private fun getGoogleDrivePreferences(): List<Preference> {
val context = LocalContext.current
val googleDriveSync = Injekt.get<GoogleDriveService>()
return listOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.pref_google_drive_sign_in),
onClick = {
val intent = googleDriveSync.getSignInIntent()
context.startActivity(intent)
},
),
getGoogleDrivePurge(),
)
}
@Composable
private fun getGoogleDrivePurge(): Preference.PreferenceItem.TextPreference {
val scope = rememberCoroutineScope()
val showPurgeDialog = remember { mutableStateOf(false) }
val context = LocalContext.current
val googleDriveSync = remember { GoogleDriveSyncService(context) }
if (showPurgeDialog.value) {
PurgeConfirmationDialog(
onConfirm = {
showPurgeDialog.value = false
scope.launch {
val result = googleDriveSync.deleteSyncDataFromGoogleDrive()
if (result) {
context.toast(R.string.google_drive_sync_data_purged)
} else {
context.toast(R.string.google_drive_sync_data_not_found)
}
}
},
onDismissRequest = { showPurgeDialog.value = false },
)
}
return Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.pref_google_drive_purge_sync_data),
onClick = { showPurgeDialog.value = true },
)
}
@Composable
fun PurgeConfirmationDialog(
onConfirm: () -> Unit,
onDismissRequest: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(R.string.pref_purge_confirmation_title)) },
text = { Text(text = stringResource(R.string.pref_purge_confirmation_message)) },
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(R.string.action_cancel))
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(android.R.string.ok))
}
},
)
}
@Composable
private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List<Preference> {
return listOf(

View File

@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.JavaScriptEngine
@ -151,6 +152,8 @@ class AppModule(val app: Application) : InjektModule {
get<DownloadManager>()
}
addSingletonFactory { GoogleDriveService(app) }
}
}

View File

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.sync.models.SyncData
import eu.kanade.tachiyomi.data.sync.models.SyncDevice
import eu.kanade.tachiyomi.data.sync.models.SyncStatus
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService
import eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
@ -56,6 +57,7 @@ class SyncManager(
enum class SyncService(val value: Int) {
NONE(0),
SYNCYOMI(1),
GOOGLE_DRIVE(2),
;
companion object {
@ -107,6 +109,10 @@ class SyncManager(
)
}
SyncService.GOOGLE_DRIVE -> {
GoogleDriveSyncService(context, json, syncPreferences)
}
else -> {
logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" }
null

View File

@ -0,0 +1,332 @@
package eu.kanade.tachiyomi.data.sync.service
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import com.google.api.client.auth.oauth2.TokenResponseException
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse
import com.google.api.client.http.ByteArrayContent
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.JsonFactory
import com.google.api.client.json.jackson2.JacksonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
import com.google.api.services.drive.model.File
import eu.kanade.tachiyomi.data.sync.models.SyncData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import logcat.LogPriority
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.sync.SyncPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: SyncPreferences) : SyncService(context, json, syncPreferences) {
constructor(context: Context) : this(
context,
Json {
encodeDefaults = true
ignoreUnknownKeys = true
},
Injekt.get<SyncPreferences>(),
)
private val remoteFileName = "tachiyomi_sync_data.gz"
private val googleDriveService = GoogleDriveService(context)
override suspend fun beforeSync() = googleDriveService.refreshToken()
override suspend fun pushSyncData(): SyncData? {
val drive = googleDriveService.googleDriveService
// Check if the Google Drive service is initialized
if (drive == null) {
logcat(LogPriority.ERROR) { "Google Drive service not initialized" }
return null
}
val fileList = getFileList(drive)
if (fileList.isEmpty()) {
return null
}
val gdriveFileId = fileList[0].id
val outputStream = ByteArrayOutputStream()
drive.files().get(gdriveFileId).executeMediaAndDownloadTo(outputStream)
val jsonString = withContext(Dispatchers.IO) {
val gzipInputStream = GZIPInputStream(ByteArrayInputStream(outputStream.toByteArray()))
gzipInputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
}
return json.decodeFromString(SyncData.serializer(), jsonString)
}
override suspend fun pullSyncData(syncData: SyncData) {
val jsonData = json.encodeToString(syncData)
val drive = googleDriveService.googleDriveService
// Check if the Google Drive service is initialized
if (drive == null) {
logcat(LogPriority.ERROR) { "Google Drive service not initialized" }
return
}
// delete file if exists
val fileList = getFileList(drive)
if (fileList.isNotEmpty()) {
drive.files().delete(fileList[0].id).execute()
}
val fileMetadata = File()
fileMetadata.name = remoteFileName
fileMetadata.mimeType = "application/gzip"
val byteArrayOutputStream = ByteArrayOutputStream()
withContext(Dispatchers.IO) {
val gzipOutputStream = GZIPOutputStream(byteArrayOutputStream)
gzipOutputStream.write(jsonData.toByteArray(Charsets.UTF_8))
gzipOutputStream.close()
}
val byteArrayContent = ByteArrayContent("application/octet-stream", byteArrayOutputStream.toByteArray())
val uploadedFile = drive.files().create(fileMetadata, byteArrayContent)
.setFields("id")
.execute()
logcat(LogPriority.DEBUG) { "Created sync data file in Google Drive with file ID: ${uploadedFile.id}" }
}
private fun getFileList(drive: Drive): MutableList<File> {
// Search for the existing file by name
val query = "mimeType='application/gzip' and trashed = false and name = '$remoteFileName'"
val fileList = drive.files().list().setQ(query).execute().files
Log.d("GoogleDrive", "File list: $fileList")
return fileList
}
suspend fun deleteSyncDataFromGoogleDrive(): Boolean {
val drive = googleDriveService.googleDriveService
if (drive == null) {
logcat(LogPriority.ERROR) { "Google Drive service not initialized" }
return false
}
googleDriveService.refreshToken()
return withContext(Dispatchers.IO) {
val query = "mimeType='application/gzip' and trashed = false and name = '$remoteFileName'"
val fileList = drive.files().list().setQ(query).execute().files
if (fileList.isNullOrEmpty()) {
logcat(LogPriority.DEBUG) { "No sync data file found in Google Drive" }
false
} else {
val fileId = fileList[0].id
drive.files().delete(fileId).execute()
logcat(LogPriority.DEBUG) { "Deleted sync data file in Google Drive with file ID: $fileId" }
true
}
}
}
}
class GoogleDriveService(private val context: Context) {
var googleDriveService: Drive? = null
private val syncPreferences = Injekt.get<SyncPreferences>()
init {
initGoogleDriveService()
}
/**
* Initializes the Google Drive service by obtaining the access token and refresh token from the SyncPreferences
* and setting up the service using the obtained tokens.
*/
private fun initGoogleDriveService() {
val accessToken = syncPreferences.getGoogleDriveAccessToken()
val refreshToken = syncPreferences.getGoogleDriveRefreshToken()
if (accessToken == "" || refreshToken == "") {
googleDriveService = null
return
}
setupGoogleDriveService(accessToken, refreshToken)
}
/**
* Launches an Intent to open the user's default browser for Google Drive sign-in.
* The Intent carries the authorization URL, which prompts the user to sign in
* and grant the application permission to access their Google Drive account.
* @return An Intent configured to launch a browser for Google Drive OAuth sign-in.
*/
fun getSignInIntent(): Intent {
val authorizationUrl = generateAuthorizationUrl()
return Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(authorizationUrl)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
/**
* Generates the authorization URL required for the user to grant the application permission to access their Google Drive account.
* Sets the approval prompt to "force" to ensure that the user is always prompted to grant access, even if they have previously granted access.
* @return The authorization URL.
*/
private fun generateAuthorizationUrl(): String {
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
jsonFactory,
InputStreamReader(context.assets.open("client_secrets.json")),
)
val flow = GoogleAuthorizationCodeFlow.Builder(
NetHttpTransport(),
jsonFactory,
secrets,
listOf(DriveScopes.DRIVE_FILE),
).setAccessType("offline").build()
return flow.newAuthorizationUrl()
.setRedirectUri("eu.kanade.google.oauth:/oauth2redirect")
.setApprovalPrompt("force")
.build()
}
internal suspend fun refreshToken() = withContext(Dispatchers.IO) {
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
jsonFactory,
InputStreamReader(context.assets.open("client_secrets.json")),
)
val credential = GoogleCredential.Builder()
.setJsonFactory(jsonFactory)
.setTransport(NetHttpTransport())
.setClientSecrets(secrets)
.build()
credential.refreshToken = syncPreferences.getGoogleDriveRefreshToken()
logcat(LogPriority.DEBUG) { "Refreshing access token with: ${syncPreferences.getGoogleDriveRefreshToken()}" }
try {
credential.refreshToken()
val newAccessToken = credential.accessToken
val oldAccessToken = syncPreferences.getGoogleDriveAccessToken()
// Save the new access token
syncPreferences.setGoogleDriveAccessToken(newAccessToken)
setupGoogleDriveService(newAccessToken, credential.refreshToken)
logcat(LogPriority.DEBUG) { "Google Access token refreshed old: $oldAccessToken new: $newAccessToken" }
} catch (e: TokenResponseException) {
if (e.details.error == "invalid_grant") {
// The refresh token is invalid, prompt the user to sign in again
logcat(LogPriority.ERROR) { "Refresh token is invalid, prompt user to sign in again" }
throw e.message?.let { Exception(it) } ?: Exception("Unknown error")
} else {
// Token refresh failed; handle this situation
logcat(LogPriority.ERROR) { "Failed to refresh access token ${e.message}" }
logcat(LogPriority.ERROR) { "Google Drive sync will be disabled" }
}
} catch (e: IOException) {
// Token refresh failed; handle this situation
logcat(LogPriority.ERROR) { "Failed to refresh access token ${e.message}" }
logcat(LogPriority.ERROR) { "Google Drive sync will be disabled" }
}
}
/**
* Sets up the Google Drive service using the provided access token and refresh token.
* @param accessToken The access token obtained from the SyncPreferences.
* @param refreshToken The refresh token obtained from the SyncPreferences.
*/
private fun setupGoogleDriveService(accessToken: String, refreshToken: String) {
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
jsonFactory,
InputStreamReader(context.assets.open("client_secrets.json")),
)
val credential = GoogleCredential.Builder()
.setJsonFactory(jsonFactory)
.setTransport(NetHttpTransport())
.setClientSecrets(secrets)
.build()
credential.accessToken = accessToken
credential.refreshToken = refreshToken
googleDriveService = Drive.Builder(
NetHttpTransport(),
jsonFactory,
credential,
).setApplicationName("Tachiyomi")
.build()
}
/**
* Handles the authorization code returned after the user has granted the application permission to access their Google Drive account.
* It obtains the access token and refresh token using the authorization code, saves the tokens to the SyncPreferences,
* sets up the Google Drive service using the obtained tokens, and initializes the service.
* @param authorizationCode The authorization code obtained from the OAuthCallbackServer.
* @param activity The current activity.
* @param onSuccess A callback function to be called on successful authorization.
* @param onFailure A callback function to be called on authorization failure.
*/
fun handleAuthorizationCode(authorizationCode: String, activity: Activity, onSuccess: () -> Unit, onFailure: (String) -> Unit) {
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
jsonFactory,
InputStreamReader(context.assets.open("client_secrets.json")),
)
val tokenResponse: GoogleTokenResponse = GoogleAuthorizationCodeTokenRequest(
NetHttpTransport(),
jsonFactory,
secrets.installed.clientId,
secrets.installed.clientSecret,
authorizationCode,
"eu.kanade.google.oauth:/oauth2redirect",
).setGrantType("authorization_code").execute()
try {
// Save the access token and refresh token
val accessToken = tokenResponse.accessToken
val refreshToken = tokenResponse.refreshToken
// Save the tokens to SyncPreferences
syncPreferences.setGoogleDriveAccessToken(accessToken)
syncPreferences.setGoogleDriveRefreshToken(refreshToken)
setupGoogleDriveService(accessToken, refreshToken)
initGoogleDriveService()
activity.runOnUiThread {
onSuccess()
}
} catch (e: Exception) {
activity.runOnUiThread {
onFailure(e.localizedMessage ?: "Unknown error")
}
}
}
}

View File

@ -106,8 +106,8 @@ abstract class SyncService(
localMangaMap.forEach { (key, localManga) ->
val remoteManga = remoteMangaMap[key]
if (remoteManga != null) {
val localInstant = localManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
val remoteInstant = remoteManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
val localInstant = localManga.lastModifiedAt.let { Instant.ofEpochMilli(it) }
val remoteInstant = remoteManga.lastModifiedAt.let { Instant.ofEpochMilli(it) }
val mergedManga = if ((localInstant ?: Instant.MIN) >= (
remoteInstant
@ -164,8 +164,8 @@ abstract class SyncService(
localChapterMap.forEach { (url, localChapter) ->
val remoteChapter = remoteChapterMap[url]
if (remoteChapter != null) {
val localInstant = localChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
val remoteInstant = remoteChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
val localInstant = localChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) }
val remoteInstant = remoteChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) }
val mergedChapter =
if ((localInstant ?: Instant.MIN) >= (remoteInstant ?: Instant.MIN)) {

View File

@ -0,0 +1,52 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import tachiyomi.core.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
private val googleDriveService = Injekt.get<GoogleDriveService>()
override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
val error = data?.getQueryParameter("error")
if (code != null) {
lifecycleScope.launchIO {
googleDriveService.handleAuthorizationCode(
code,
this@GoogleDriveLoginActivity,
onSuccess = {
Toast.makeText(
this@GoogleDriveLoginActivity,
"Authorization successful.",
Toast.LENGTH_LONG,
).show()
returnToSettings()
},
onFailure = { error ->
Toast.makeText(
this@GoogleDriveLoginActivity,
"Authorization failed: $error",
Toast.LENGTH_LONG,
).show()
returnToSettings()
},
)
}
} else if (error != null) {
Toast.makeText(
this@GoogleDriveLoginActivity,
"Authorization failed: $error",
Toast.LENGTH_LONG,
).show()
returnToSettings()
} else {
returnToSettings()
}
}
}

View File

@ -15,4 +15,20 @@ class SyncPreferences(
fun deviceName() = preferenceStore.getString("device_name", android.os.Build.MANUFACTURER + android.os.Build.PRODUCT)
fun syncService() = preferenceStore.getInt("sync_service", 0)
private fun googleDriveAccessToken() = preferenceStore.getString("google_drive_access_token", "")
fun setGoogleDriveAccessToken(accessToken: String) {
googleDriveAccessToken().set(accessToken)
}
fun getGoogleDriveAccessToken() = googleDriveAccessToken().get()
private fun googleDriveRefreshToken() = preferenceStore.getString("google_drive_refresh_token", "")
fun setGoogleDriveRefreshToken(refreshToken: String) {
googleDriveRefreshToken().set(refreshToken)
}
fun getGoogleDriveRefreshToken() = googleDriveRefreshToken().get()
}

View File

@ -91,6 +91,9 @@ voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.
voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
google-api-services-drive = "com.google.apis:google-api-services-drive:v3-rev197-1.25.0"
google-api-client-oauth = "com.google.oauth-client:google-oauth-client:1.34.1"
kotlinter = "org.jmailen.gradle:kotlinter-gradle:3.13.0"
[bundles]

View File

@ -530,6 +530,18 @@
<string name="syncyomi">SyncYomi</string>
<string name="sync_completed_message">Done in %1$s</string>
<string name="last_synchronization">Last Synchronization: %1$s</string>
<string name="google_drive">Google Drive</string>
<string name="pref_google_drive_sign_in">Sign in</string>
<string name="google_drive_sign_in_success">Signed in successfully</string>
<string name="google_drive_sign_in_failed">Sign in failed</string>
<string name="authentication">Authentication</string>
<string name="pref_google_drive_purge_sync_data">Clear Sync Data from Google Drive</string>
<string name="google_drive_sync_data_purged">Sync data purged from Google Drive</string>
<string name="google_drive_sync_data_not_found">No sync data found in Google Drive</string>
<string name="google_drive_not_signed_in">Not signed in to Google Drive</string>
<string name="pref_purge_confirmation_title">Purge confirmation</string>
<string name="pref_purge_confirmation_message">Purging sync data will delete all your sync data from Google Drive. Are you sure you want to continue?</string>
<!-- Advanced section -->
<string name="label_network">Network</string>