diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2542c1c3d8..3cd56e7a1d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e8f9b52202..51c91f9c52 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -73,4 +73,10 @@ # Firebase -keep class com.google.firebase.installations.** { *; } --keep interface com.google.firebase.installations.** { *; } \ No newline at end of file +-keep interface com.google.firebase.installations.** { *; } + +# Google Drive +-keep class com.google.api.services.** { *; } + +# Google OAuth +-keep class com.google.api.client.** { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5eefefd442..3ba6e730c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -183,6 +183,20 @@ android:scheme="tachiyomi" /> + + + + + + + + + + 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 { + val context = LocalContext.current + val googleDriveSync = Injekt.get() + 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 { return listOf( diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 3949565ab4..ab8cf126d9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -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() } + + addSingletonFactory { GoogleDriveService(app) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt index 7bb5f16103..49312fb7db 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt new file mode 100644 index 0000000000..e517a9ce83 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt @@ -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(), + ) + + 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 { + // 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() + + 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") + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt index 39eac54834..5440567fc5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt @@ -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)) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt new file mode 100644 index 0000000000..ae95b919b1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt @@ -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() + 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() + } + } +} diff --git a/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt b/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt index 7b01c1d6f6..8e7fa8499f 100644 --- a/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt @@ -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() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1070683d6..916d76aa4f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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] diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 319f644e38..56ae1dfb22 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -530,6 +530,18 @@ SyncYomi Done in %1$s Last Synchronization: %1$s + Google Drive + Sign in + Signed in successfully + Sign in failed + Authentication + Clear Sync Data from Google Drive + Sync data purged from Google Drive + No sync data found in Google Drive + Not signed in to Google Drive + Purge confirmation + Purging sync data will delete all your sync data from Google Drive. Are you sure you want to continue? + Network