mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-11-20 19:09:17 +01:00
feat: added google drive service.
Signed-off-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
parent
536c5facb9
commit
645505e1e9
@ -257,6 +257,9 @@ dependencies {
|
|||||||
// For detecting memory leaks; see https://square.github.io/leakcanary/
|
// For detecting memory leaks; see https://square.github.io/leakcanary/
|
||||||
// debugImplementation(libs.leakcanary.android)
|
// debugImplementation(libs.leakcanary.android)
|
||||||
implementation(libs.leakcanary.plumber)
|
implementation(libs.leakcanary.plumber)
|
||||||
|
|
||||||
|
implementation(libs.google.api.services.drive)
|
||||||
|
implementation(libs.google.api.client.oauth)
|
||||||
}
|
}
|
||||||
|
|
||||||
androidComponents {
|
androidComponents {
|
||||||
|
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@ -74,3 +74,9 @@
|
|||||||
# Firebase
|
# Firebase
|
||||||
-keep class com.google.firebase.installations.** { *; }
|
-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.** { *; }
|
@ -183,6 +183,20 @@
|
|||||||
android:scheme="tachiyomi" />
|
android:scheme="tachiyomi" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<receiver
|
||||||
android:name=".data.notification.NotificationReceiver"
|
android:name=".data.notification.NotificationReceiver"
|
||||||
|
1
app/src/main/assets/client_secrets.json
Normal file
1
app/src/main/assets/client_secrets.json
Normal 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"}}
|
@ -52,6 +52,8 @@ import eu.kanade.tachiyomi.data.backup.BackupRestoreJob
|
|||||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
import eu.kanade.tachiyomi.data.sync.SyncDataJob
|
import eu.kanade.tachiyomi.data.sync.SyncDataJob
|
||||||
import eu.kanade.tachiyomi.data.sync.SyncManager
|
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.storage.DiskUtil
|
||||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||||
@ -94,6 +96,7 @@ object SettingsBackupAndSyncScreen : SearchableSettings {
|
|||||||
entries = mapOf(
|
entries = mapOf(
|
||||||
SyncManager.SyncService.NONE.value to stringResource(R.string.off),
|
SyncManager.SyncService.NONE.value to stringResource(R.string.off),
|
||||||
SyncManager.SyncService.SYNCYOMI.value to stringResource(R.string.syncyomi),
|
SyncManager.SyncService.SYNCYOMI.value to stringResource(R.string.syncyomi),
|
||||||
|
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(R.string.google_drive),
|
||||||
),
|
),
|
||||||
onValueChanged = { true },
|
onValueChanged = { true },
|
||||||
),
|
),
|
||||||
@ -448,6 +451,7 @@ object SettingsBackupAndSyncScreen : SearchableSettings {
|
|||||||
return when (syncServiceType) {
|
return when (syncServiceType) {
|
||||||
SyncManager.SyncService.NONE -> emptyList()
|
SyncManager.SyncService.NONE -> emptyList()
|
||||||
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
|
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
|
||||||
|
SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences()
|
||||||
} +
|
} +
|
||||||
if (syncServiceType == SyncManager.SyncService.NONE) {
|
if (syncServiceType == SyncManager.SyncService.NONE) {
|
||||||
emptyList()
|
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
|
@Composable
|
||||||
private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List<Preference> {
|
private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List<Preference> {
|
||||||
return listOf(
|
return listOf(
|
||||||
|
@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache
|
|||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
||||||
import eu.kanade.tachiyomi.data.saver.ImageSaver
|
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.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.network.JavaScriptEngine
|
import eu.kanade.tachiyomi.network.JavaScriptEngine
|
||||||
@ -151,6 +152,8 @@ class AppModule(val app: Application) : InjektModule {
|
|||||||
|
|
||||||
get<DownloadManager>()
|
get<DownloadManager>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addSingletonFactory { GoogleDriveService(app) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.SyncData
|
||||||
import eu.kanade.tachiyomi.data.sync.models.SyncDevice
|
import eu.kanade.tachiyomi.data.sync.models.SyncDevice
|
||||||
import eu.kanade.tachiyomi.data.sync.models.SyncStatus
|
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 eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
@ -56,6 +57,7 @@ class SyncManager(
|
|||||||
enum class SyncService(val value: Int) {
|
enum class SyncService(val value: Int) {
|
||||||
NONE(0),
|
NONE(0),
|
||||||
SYNCYOMI(1),
|
SYNCYOMI(1),
|
||||||
|
GOOGLE_DRIVE(2),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -107,6 +109,10 @@ class SyncManager(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SyncService.GOOGLE_DRIVE -> {
|
||||||
|
GoogleDriveSyncService(context, json, syncPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" }
|
logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" }
|
||||||
null
|
null
|
||||||
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -106,8 +106,8 @@ abstract class SyncService(
|
|||||||
localMangaMap.forEach { (key, localManga) ->
|
localMangaMap.forEach { (key, localManga) ->
|
||||||
val remoteManga = remoteMangaMap[key]
|
val remoteManga = remoteMangaMap[key]
|
||||||
if (remoteManga != null) {
|
if (remoteManga != null) {
|
||||||
val localInstant = localManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
|
val localInstant = localManga.lastModifiedAt.let { Instant.ofEpochMilli(it) }
|
||||||
val remoteInstant = remoteManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
|
val remoteInstant = remoteManga.lastModifiedAt.let { Instant.ofEpochMilli(it) }
|
||||||
|
|
||||||
val mergedManga = if ((localInstant ?: Instant.MIN) >= (
|
val mergedManga = if ((localInstant ?: Instant.MIN) >= (
|
||||||
remoteInstant
|
remoteInstant
|
||||||
@ -164,8 +164,8 @@ abstract class SyncService(
|
|||||||
localChapterMap.forEach { (url, localChapter) ->
|
localChapterMap.forEach { (url, localChapter) ->
|
||||||
val remoteChapter = remoteChapterMap[url]
|
val remoteChapter = remoteChapterMap[url]
|
||||||
if (remoteChapter != null) {
|
if (remoteChapter != null) {
|
||||||
val localInstant = localChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
|
val localInstant = localChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) }
|
||||||
val remoteInstant = remoteChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
|
val remoteInstant = remoteChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) }
|
||||||
|
|
||||||
val mergedChapter =
|
val mergedChapter =
|
||||||
if ((localInstant ?: Instant.MIN) >= (remoteInstant ?: Instant.MIN)) {
|
if ((localInstant ?: Instant.MIN) >= (remoteInstant ?: Instant.MIN)) {
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -15,4 +15,20 @@ class SyncPreferences(
|
|||||||
fun deviceName() = preferenceStore.getString("device_name", android.os.Build.MANUFACTURER + android.os.Build.PRODUCT)
|
fun deviceName() = preferenceStore.getString("device_name", android.os.Build.MANUFACTURER + android.os.Build.PRODUCT)
|
||||||
|
|
||||||
fun syncService() = preferenceStore.getInt("sync_service", 0)
|
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()
|
||||||
}
|
}
|
||||||
|
@ -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-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
|
||||||
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", 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"
|
kotlinter = "org.jmailen.gradle:kotlinter-gradle:3.13.0"
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
|
@ -530,6 +530,18 @@
|
|||||||
<string name="syncyomi">SyncYomi</string>
|
<string name="syncyomi">SyncYomi</string>
|
||||||
<string name="sync_completed_message">Done in %1$s</string>
|
<string name="sync_completed_message">Done in %1$s</string>
|
||||||
<string name="last_synchronization">Last Synchronization: %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 -->
|
<!-- Advanced section -->
|
||||||
<string name="label_network">Network</string>
|
<string name="label_network">Network</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user