diff --git a/README.org b/README.org index 4b07aa5cf..bdb750319 100644 --- a/README.org +++ b/README.org @@ -8,7 +8,7 @@ Orgzly is an outliner for taking notes and managing to-do lists. You can keep notebooks stored in plain-text and have them synchronized -with a directory on your mobile device, SD card, WebDAV server or Dropbox. +with a directory on your mobile device, SD card, WebDAV server, Dropbox or Google Drive. Notebooks are saved in /Org mode/'s file format. “Org mode is for keeping notes, maintaining TODO lists, planning projects, and diff --git a/app/build.gradle b/app/build.gradle index abedd005b..96ad7e9f1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,6 +62,8 @@ android { premium { buildConfigField "boolean", "IS_DROPBOX_ENABLED", "true" + buildConfigField "boolean", "IS_GOOGLE_DRIVE_ENABLED", "true" + buildConfigField "String", "VERSION_NAME_SUFFIX", '""' dimension "store" @@ -69,11 +71,13 @@ android { fdroid { /* - * Disable Dropbox. + * Disable Dropbox and Google Drive. * Properties file which contains the required API key is not included with the code. */ buildConfigField "boolean", "IS_DROPBOX_ENABLED", "false" + buildConfigField "boolean", "IS_GOOGLE_DRIVE_ENABLED", "false" + buildConfigField "String", "VERSION_NAME_SUFFIX", '" (fdroid)"' dimension "store" @@ -88,7 +92,6 @@ android { kotlinOptions { jvmTarget = 11 } - packagingOptions { resources { excludes += ['META-INF/DEPENDENCIES', 'plugin.properties'] @@ -99,6 +102,10 @@ android { checkDependencies true disable 'MissingTranslation', 'MissingQuantity', 'ImpliedQuantity', 'InvalidPackage' } + lint { + disable 'MissingTranslation', 'MissingQuantity', 'ImpliedQuantity', 'InvalidPackage' + } + } dependencies { @@ -164,6 +171,18 @@ dependencies { implementation "com.dropbox.core:dropbox-core-sdk:$versions.dropbox_core_sdk" + // Google Drive + implementation 'com.google.android.gms:play-services-auth:19.0.0' + implementation 'com.google.http-client:google-http-client-gson:1.26.0' + implementation('com.google.api-client:google-api-client-android:1.26.0') { + exclude group: 'org.apache.httpcomponents' + } + implementation('com.google.apis:google-api-services-drive:v3-rev136-1.25.0') { + exclude group: 'org.apache.httpcomponents' + } + // just to avoid duplicate class build error + implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' + implementation "com.googlecode.juniversalchardet:juniversalchardet:$versions.juniversalchardet" implementation "joda-time:joda-time:$versions.joda_time" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c9a8755f3..2624f2793 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -99,6 +99,11 @@ android:windowSoftInputMode="stateAlwaysHidden"> + + + 1 url.startsWith("dropbox") -> 2 - url.startsWith("file") -> 3 - url.startsWith("content") -> 4 - url.matches("^(webdav|dav|http)s?.*".toRegex()) -> 5 + url.startsWith("google") -> 3 + url.startsWith("file") -> 4 + url.startsWith("content") -> 5 + url.matches("^(webdav|dav|http)s?.*".toRegex()) -> 6 else -> throw IllegalArgumentException("Unknown repo $url") } diff --git a/app/src/main/java/com/orgzly/android/di/AppComponent.kt b/app/src/main/java/com/orgzly/android/di/AppComponent.kt index d04a70f58..d75b2e619 100644 --- a/app/src/main/java/com/orgzly/android/di/AppComponent.kt +++ b/app/src/main/java/com/orgzly/android/di/AppComponent.kt @@ -24,6 +24,7 @@ import com.orgzly.android.ui.refile.RefileFragment import com.orgzly.android.ui.repo.BrowserActivity import com.orgzly.android.ui.repo.directory.DirectoryRepoActivity import com.orgzly.android.ui.repo.dropbox.DropboxRepoActivity +import com.orgzly.android.ui.repo.googledrive.GoogleDriveRepoActivity import com.orgzly.android.ui.repo.git.GitRepoActivity import com.orgzly.android.ui.repo.webdav.WebdavRepoActivity import com.orgzly.android.ui.repos.ReposActivity @@ -51,6 +52,7 @@ interface AppComponent { fun inject(arg: MainActivity) fun inject(arg: ReposActivity) fun inject(arg: DropboxRepoActivity) + fun inject(arg: GoogleDriveRepoActivity) fun inject(arg: DirectoryRepoActivity) fun inject(arg: WebdavRepoActivity) fun inject(arg: GitRepoActivity) @@ -84,4 +86,4 @@ interface AppComponent { fun inject(arg: TimeChangeBroadcastReceiver) fun inject(arg: RemindersBroadcastReceiver) fun inject(arg: SharingShortcutsManager) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/orgzly/android/repos/GoogleDriveClient.java b/app/src/main/java/com/orgzly/android/repos/GoogleDriveClient.java new file mode 100644 index 000000000..2d54588b2 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/repos/GoogleDriveClient.java @@ -0,0 +1,379 @@ +package com.orgzly.android.repos; + +import android.content.Context; +import android.net.Uri; +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import com.google.android.gms.auth.api.signin.GoogleSignIn; + +import com.google.android.gms.auth.api.signin.GoogleSignInAccount; +import com.google.api.client.extensions.android.http.AndroidHttp; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential; +import com.google.api.client.http.FileContent; +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 com.google.api.services.drive.model.FileList; + +import com.orgzly.android.BookName; + +import java.io.BufferedOutputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class GoogleDriveClient { + private static final String TAG = GoogleDriveClient.class.getName(); + + private static final long UPLOAD_FILE_SIZE_LIMIT = 150; // MB + + // TODO: Throw GoogleDriveNotLinked etc. instead and let the client get message from resources + private static final String NOT_LINKED = "Not linked to Google Drive"; + private static final String LARGE_FILE = "File larger then " + UPLOAD_FILE_SIZE_LIMIT + " MB"; + + /* Using the empty string ("") to represent the root folder. */ + private static final String ROOT_PATH = ""; + + private final Context mContext; + private final long repoId; + private static Drive mDriveService; + + private static Map pathIds; + static { + pathIds = new HashMap<>(); + pathIds.put("My Drive", "root"); + pathIds.put("", "root"); + } + + public GoogleDriveClient(Context context, long id) { + mContext = context; + + repoId = id; + + if (isLinked()) setDriveService(); + } + + public boolean isLinked() { + // Check for existing Google Sign In account, if the user is already signed in + // the GoogleSignInAccount will be non-null. + return getGoogleAccount() != null; + } + + private GoogleSignInAccount getGoogleAccount() { + return GoogleSignIn.getLastSignedInAccount(mContext); + } + + public Drive getDriveService(GoogleSignInAccount googleAccount) { + GoogleAccountCredential credential = GoogleAccountCredential.usingOAuth2( + mContext, Collections.singleton(DriveScopes.DRIVE)); + credential.setSelectedAccount(googleAccount.getAccount()); + return new Drive.Builder( + AndroidHttp.newCompatibleTransport(), + new GsonFactory(), + credential) + .setApplicationName("Orgzly") + .build(); + } + + public Drive getDriveService() { + return getDriveService(getGoogleAccount()); + } + + public void setDriveService() { + if (mDriveService == null) mDriveService = getDriveService(); + } + + public void setDriveService(GoogleSignInAccount googleAccount) { + mDriveService = getDriveService(googleAccount); + } + + private void linkedOrThrow() throws IOException { + if (! isLinked()) { + throw new IOException(NOT_LINKED); + } else { + setDriveService(); + } + } + + @RequiresApi(api = Build.VERSION_CODES.N) + private String findId(String path) throws IOException { + if (pathIds.containsKey(path)) { + return pathIds.get(path); + } + + String[] parts = path.split("/"); + parts = Arrays.stream(parts).filter(s -> !s.isEmpty()).toArray(String[]::new); + String[] ids = new String[parts.length+1]; + + ids[0] = "root"; + + for (int i=0; i < parts.length; ++i) { + FileList result = mDriveService.files().list() + .setQ(String.format("name = '%s' and '%s' in parents", parts[i], ids[i])) + .setSpaces("drive") + .setFields("files(id, name, mimeType)") + .execute(); + List files = result.getFiles(); + if (!files.isEmpty()) { + File file = (File) files.get(0); + ids[i+1] = file.getId(); + } + } + + if (ids[ids.length-1] != null) { + pathIds.put(path, ids[ids.length-1]); + } + + return ids[ids.length-1]; // Returns null if no file is found + } + + @RequiresApi(api = Build.VERSION_CODES.N) + public List getBooks(Uri repoUri) throws IOException { + linkedOrThrow(); + + List list = new ArrayList<>(); + + String path = repoUri.getPath(); + + /* Fix root path. */ + if (path == null || path.equals("/")) { + path = ROOT_PATH; + } + + /* Strip trailing slashes. */ + path = path.replaceAll("/+$", ""); + + try { + + String pathId = findId(path); + + if (pathId != null) { + + File folder = mDriveService.files().get(pathId) + .setFields("id, name, mimeType, version, modifiedTime") + .execute(); + + if (folder.getMimeType().equals("application/vnd.google-apps.folder")) { + + String pageToken = null; + do { + FileList result = mDriveService.files().list() + .setQ(String.format("mimeType != 'application/vnd.google-apps.folder' " + + "and '%s' in parents and trashed = false", pathId)) + .setSpaces("drive") + .setFields("nextPageToken, files(id, name, mimeType, version, modifiedTime)") + .setPageToken(pageToken) + .execute(); + for (File file : result.getFiles()) { + if(BookName.isSupportedFormatFileName(file.getName())) { + Uri uri = repoUri.buildUpon().appendPath(file.getName()).build(); + VersionedRook book = new VersionedRook( + repoId, + RepoType.GOOGLE_DRIVE, + repoUri, + uri, + Long.toString(file.getVersion()), + file.getModifiedTime().getValue()); + + list.add(book); + } + } + pageToken = result.getNextPageToken(); + } while (pageToken != null); + + } else { + throw new IOException("Not a directory: " + repoUri); + } + } else { + throw new IOException("Path not found: " + repoUri); + } + + } catch (Exception e) { + e.printStackTrace(); + + throw new IOException("Failed getting the list of files in " + repoUri + + " listing " + path + ": " + + (e.getMessage() != null ? e.getMessage() : e.toString())); + } + + return list; + } + + /** + * Download file from Google Drive and store it to a local file. + */ + @RequiresApi(api = Build.VERSION_CODES.N) + public VersionedRook download(Uri repoUri, String fileName, java.io.File localFile) throws IOException { + linkedOrThrow(); + + Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + + OutputStream out = new BufferedOutputStream(new FileOutputStream(localFile)); + + try { + + String fileId = findId(uri.getPath()); + + if (fileId != null) { + File file = mDriveService.files().get(fileId) + .setFields("id, mimeType, version, modifiedTime") + .execute(); + + if (!file.getMimeType().equals("application/vnd.google-apps.folder")) { + + String rev = Long.toString(file.getVersion()); + long mtime = file.getModifiedTime().getValue(); + + mDriveService.files().get(fileId).executeMediaAndDownloadTo(out); + + return new VersionedRook(repoId, RepoType.GOOGLE_DRIVE, repoUri, uri, rev, mtime); + + } else { + throw new IOException("Failed downloading Google Drive file " + uri + ": Not a file"); + } + } else { + throw new IOException("Failed downloading Google Drive file " + uri + ": File not found"); + } + } catch (Exception e) { + if (e.getMessage() != null) { + throw new IOException("Failed downloading Google Drive file " + uri + ": " + e.getMessage()); + } else { + throw new IOException("Failed downloading Google Drive file " + uri + ": " + e); + } + } finally { + out.close(); + } + } + + + /** Upload file to Google Drive. */ + @RequiresApi(api = Build.VERSION_CODES.N) + public VersionedRook upload(java.io.File file, Uri repoUri, String fileName) throws IOException { + linkedOrThrow(); + + Uri bookUri = repoUri.buildUpon().appendPath(fileName).build(); + + if (file.length() > UPLOAD_FILE_SIZE_LIMIT * 1024 * 1024) { + throw new IOException(LARGE_FILE); + } + + // FileMetadata metadata; + // InputStream in = new FileInputStream(file); + + File fileMetadata = new File(); + String filePath = bookUri.getPath(); + + try { + fileMetadata.setName(fileName); + fileMetadata.setTrashed(false); + FileContent mediaContent = new FileContent("text/plain", file); + + String fileId = findId(filePath); + + if (fileId == null) { + filePath = "/" + filePath; // Avoids errors when file is in root folder + String folderPath = filePath.substring(0, filePath.lastIndexOf('/')); + String folderId = findId(folderPath); + + fileMetadata.setParents(Collections.singletonList(folderId)); + fileMetadata = mDriveService.files().create(fileMetadata, mediaContent) + .setFields("id, parents") + .execute(); + fileId = fileMetadata.getId(); + + pathIds.put(filePath, fileId); + } else { + fileMetadata = mDriveService.files().update(fileId, fileMetadata, mediaContent) + .setFields("id, version, modifiedTime") + .execute(); + } + + } catch (Exception e) { + if (e.getMessage() != null) { + throw new IOException("Failed overwriting " + filePath + " on Google Drive: " + e.getMessage()); + } else { + throw new IOException("Failed overwriting " + filePath + " on Google Drive: " + e); + } + } + + String rev = Long.toString(fileMetadata.getVersion()); + long mtime = fileMetadata.getModifiedTime().getValue(); + + return new VersionedRook(repoId, RepoType.GOOGLE_DRIVE, repoUri, bookUri, rev, mtime); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + public void delete(String path) throws IOException { + linkedOrThrow(); + + try { + String fileId = findId(path); + + if (fileId != null) { + File file = mDriveService.files().get(fileId).setFields("id, mimeType").execute(); + if (!file.getMimeType().equals("application/vnd.google-apps.folder")) { + File fileMetadata = new File(); + fileMetadata.setTrashed(true); + mDriveService.files().update(fileId, fileMetadata).execute(); + } else { + throw new IOException("Not a file: " + path); + } + } + + } catch (Exception e) { + e.printStackTrace(); + + if (e.getMessage() != null) { + throw new IOException("Failed deleting " + path + " on Google Drive: " + e.getMessage()); + } else { + throw new IOException("Failed deleting " + path + " on Google Drive: " + e); + } + } + } + + @RequiresApi(api = Build.VERSION_CODES.N) + public VersionedRook move(Uri repoUri, Uri from, Uri to) throws IOException { + linkedOrThrow(); + + try { + String fileId = findId(from.getPath()); + + File fileMetadata = new File(); + fileMetadata.setName(to.getPath()); + + if (fileId != null) { + fileMetadata = mDriveService.files().update(fileId, fileMetadata) + .setFields("id, mimeType, version, modifiedTime") + .execute(); + + if (fileMetadata.getMimeType().equals("application/vnd.google-apps.folder")) { + throw new IOException("Relocated object not a file?"); + } + + } + + String rev = Long.toString(fileMetadata.getVersion()); + long mtime = fileMetadata.getModifiedTime().getValue(); + + return new VersionedRook(repoId, RepoType.GOOGLE_DRIVE, repoUri, to, rev, mtime); + + } catch (Exception e) { + e.printStackTrace(); + + if (e.getMessage() != null) { // TODO: Move this throwing to utils + throw new IOException("Failed moving " + from + " to " + to + ": " + e.getMessage(), e); + } else { + throw new IOException("Failed moving " + from + " to " + to + ": " + e, e); + } + } + } +} diff --git a/app/src/main/java/com/orgzly/android/repos/GoogleDriveRepo.java b/app/src/main/java/com/orgzly/android/repos/GoogleDriveRepo.java new file mode 100644 index 000000000..ef3d69cfe --- /dev/null +++ b/app/src/main/java/com/orgzly/android/repos/GoogleDriveRepo.java @@ -0,0 +1,69 @@ +package com.orgzly.android.repos; + +import android.content.Context; +import android.net.Uri; + +import com.orgzly.android.util.UriUtils; + +import java.io.File; +import java.io.IOException; + +import java.util.List; + +public class GoogleDriveRepo implements SyncRepo { + public static final String SCHEME = "googledrive"; + + private final Uri repoUri; + private final GoogleDriveClient client; + + public GoogleDriveRepo(RepoWithProps repoWithProps, Context context) { + this.repoUri = Uri.parse(repoWithProps.getRepo().getUrl()); + this.client = new GoogleDriveClient(context, repoWithProps.getRepo().getId()); + } + + @Override + public boolean isConnectionRequired() { + return true; + } + + @Override + public boolean isAutoSyncSupported() { + return false; + } + + @Override + public Uri getUri() { + return repoUri; + } + + @Override + public List getBooks() throws IOException { + return client.getBooks(repoUri); + } + + @Override + public VersionedRook retrieveBook(String fileName, File file) throws IOException { + return client.download(repoUri, fileName, file); + } + + @Override + public VersionedRook storeBook(File file, String fileName) throws IOException { + return client.upload(file, repoUri, fileName); + } + + @Override + public VersionedRook renameBook(Uri fromUri, String name) throws IOException { + Uri toUri = UriUtils.getUriForNewName(fromUri, name); + return client.move(repoUri, fromUri, toUri); + } + + @Override + public void delete(Uri uri) throws IOException { + client.delete(uri.getPath()); + } + + @Override + public String toString() { + return repoUri.toString(); + } +} diff --git a/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt b/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt index 25ad16cc7..0c23a2102 100644 --- a/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt +++ b/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt @@ -22,6 +22,9 @@ class RepoFactory @Inject constructor( type == RepoType.DROPBOX.id && BuildConfig.IS_DROPBOX_ENABLED -> DropboxRepo(repoWithProps, context) + type == RepoType.GOOGLE_DRIVE.id && BuildConfig.IS_GOOGLE_DRIVE_ENABLED -> + GoogleDriveRepo(repoWithProps, context) + type == RepoType.DIRECTORY.id -> DirectoryRepo(repoWithProps, false) diff --git a/app/src/main/java/com/orgzly/android/repos/RepoType.kt b/app/src/main/java/com/orgzly/android/repos/RepoType.kt index e72cd0d49..a631808f7 100644 --- a/app/src/main/java/com/orgzly/android/repos/RepoType.kt +++ b/app/src/main/java/com/orgzly/android/repos/RepoType.kt @@ -5,10 +5,11 @@ import java.lang.IllegalArgumentException enum class RepoType(val id: Int) { MOCK(1), DROPBOX(2), - DIRECTORY(3), - DOCUMENT(4), - WEBDAV(5), - GIT(6); + GOOGLE_DRIVE(3), + DIRECTORY(4), + DOCUMENT(5), + WEBDAV(6), + GIT(7); companion object { @JvmStatic @@ -16,13 +17,14 @@ enum class RepoType(val id: Int) { return when (type) { 1 -> MOCK 2 -> DROPBOX - 3 -> DIRECTORY - 4 -> DOCUMENT - 5 -> WEBDAV - 6 -> GIT + 3 -> GOOGLE_DRIVE + 4 -> DIRECTORY + 5 -> DOCUMENT + 6 -> WEBDAV + 7 -> GIT else -> throw IllegalArgumentException("Unknown repo type id $type") } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/orgzly/android/ui/repo/googledrive/GoogleDriveRepoActivity.kt b/app/src/main/java/com/orgzly/android/ui/repo/googledrive/GoogleDriveRepoActivity.kt new file mode 100644 index 000000000..65ac60b0a --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/repo/googledrive/GoogleDriveRepoActivity.kt @@ -0,0 +1,319 @@ +package com.orgzly.android.ui.repo.googledrive + +import android.app.Activity +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.TextUtils +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.repos.GoogleDriveClient +import com.orgzly.android.repos.GoogleDriveRepo +import com.orgzly.android.repos.RepoFactory +import com.orgzly.android.repos.RepoType +import com.orgzly.android.ui.CommonActivity +import com.orgzly.android.ui.repo.RepoViewModel +import com.orgzly.android.ui.repo.RepoViewModelFactory +import com.orgzly.android.ui.showSnackbar +import com.orgzly.android.ui.util.ActivityUtils +import com.orgzly.android.ui.util.styledAttributes +import com.orgzly.android.util.LogUtils +import com.orgzly.android.util.MiscUtils +import com.orgzly.android.util.UriUtils +import com.orgzly.databinding.ActivityRepoGoogleDriveBinding +import javax.inject.Inject + +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.Scope +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.OnCompleteListener + +import com.google.api.services.drive.DriveScopes +import androidx.annotation.NonNull +import android.util.Log + +class GoogleDriveRepoActivity : CommonActivity() { + private lateinit var binding: ActivityRepoGoogleDriveBinding + + @Inject + lateinit var repoFactory: RepoFactory + + private lateinit var client: GoogleDriveClient + + private val REQUEST_CODE_SIGN_IN = 1 + + private lateinit var gsiClient: GoogleSignInClient + + private lateinit var viewModel: RepoViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + App.appComponent.inject(this) + + super.onCreate(savedInstanceState) + + binding = ActivityRepoGoogleDriveBinding.inflate(layoutInflater) + + setContentView(binding.root) + + /* Google Drive link / unlink button. */ + binding.activityRepoGoogleDriveLinkButton.setOnClickListener { + if (isGoogleDriveLinked()) { + toggleLinkAfterConfirmation() + } else { + toggleLink() + } + } + + // binding.activityRepoGoogleDriveLinkButton.setOnLongClickListener { + // editAccessToken() + // true + // } + + // Not working when done in XML + binding.activityRepoGoogleDriveDirectory.apply { + setHorizontallyScrolling(false) + + maxLines = 3 + + setOnEditorActionListener { _, _, _ -> + saveAndFinish() + finish() + true + } + } + + val repoId = intent.getLongExtra(ARG_REPO_ID, 0) + + val factory = RepoViewModelFactory.getInstance(dataRepository, repoId) + + viewModel = ViewModelProvider(this, factory).get(RepoViewModel::class.java) + + if (viewModel.repoId != 0L) { // Editing existing + viewModel.loadRepoProperties()?.let { repoWithProps -> + val path = Uri.parse(repoWithProps.repo.url).path + + binding.activityRepoGoogleDriveDirectory.setText(path) + } + } + + viewModel.finishEvent.observeSingle(this, Observer { + finish() + }) + + viewModel.alreadyExistsEvent.observeSingle(this, Observer { + showSnackbar(R.string.repository_url_already_exists) + }) + + viewModel.errorEvent.observeSingle(this, Observer { error -> + if (error != null) { + showSnackbar((error.cause ?: error).localizedMessage) + } + }) + + MiscUtils.clearErrorOnTextChange( + binding.activityRepoGoogleDriveDirectory, + binding.activityRepoGoogleDriveDirectoryInputLayout) + + ActivityUtils.openSoftKeyboard(this, binding.activityRepoGoogleDriveDirectory) + + client = GoogleDriveClient(applicationContext, repoId) + + binding.bottomAppBar.run { + setNavigationOnClickListener { + finish() + } + } + + binding.fab.setOnClickListener { + saveAndFinish() + } + + createSignInClient() + } + + fun createSignInClient() { + Log.d(TAG, "Creating sign-in client") + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(Scope(DriveScopes.DRIVE)) + .build() + gsiClient = GoogleSignIn.getClient(this, signInOptions) + } + + override fun onActivityResult(requestCode:Int, resultCode:Int, resultData:Intent?) { + super.onActivityResult(requestCode, resultCode, resultData) + // Result returned from launching the Intent from GoogleSignInClient.getSignInIntent(...); + handleSignInResult(requestCode, resultData) + } + + private fun handleSignInResult(requestCode:Int, result:Intent?) { + if (requestCode == REQUEST_CODE_SIGN_IN) + { + GoogleSignIn.getSignedInAccountFromIntent(result) + .addOnSuccessListener { googleAccount -> + Log.d(TAG, "Signed in as " + googleAccount.email) + // Use the authenticated account to sign in to the Drive service. + client.setDriveService(googleAccount) + showSnackbar(R.string.message_google_drive_linked) + } + .addOnFailureListener { exception -> Log.d(TAG, "Unable to sign in.$exception") } + } + } + + public override fun onResume() { + super.onResume() + + updateGoogleDriveLinkUnlinkButton() + } + + // override fun onCreateOptionsMenu(menu: Menu): Boolean { + // super.onCreateOptionsMenu(menu) + // + // menuInflater.inflate(R.menu.done, menu) + // + // return true + // } + // + // override fun onOptionsItemSelected(item: MenuItem): Boolean { + // return when (item.itemId) { + // R.id.done -> { + // saveAndFinish() + // true + // } + // + // android.R.id.home -> { + // finish() + // true + // } + // + // else -> + // super.onOptionsItemSelected(item) + // } + // } + + private fun saveAndFinish() { + val directory = binding.activityRepoGoogleDriveDirectory.text.toString().trim { it <= ' ' } + + if (TextUtils.isEmpty(directory)) { + binding.activityRepoGoogleDriveDirectoryInputLayout.error = getString(R.string.can_not_be_empty) + return + } else { + binding.activityRepoGoogleDriveDirectoryInputLayout.error = null + } + + val url = UriUtils.uriFromPath(GoogleDriveRepo.SCHEME, directory).toString() + + val repo = try { + viewModel.validate(RepoType.GOOGLE_DRIVE, url) + } catch (e: Exception) { + e.printStackTrace() + binding.activityRepoGoogleDriveDirectoryInputLayout.error = + getString(R.string.repository_not_valid_with_reason, e.message) + return + } + + viewModel.saveRepo(RepoType.GOOGLE_DRIVE, repo.uri.toString()) + } + + private fun toggleLinkAfterConfirmation() { + val dialogClickListener = DialogInterface.OnClickListener { _, which -> + if (which == DialogInterface.BUTTON_POSITIVE) { + toggleLink() + } + } + + alertDialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.confirm_unlinking_from_google_drive_title) + .setMessage(R.string.confirm_unlinking_from_google_drive_message) + .setPositiveButton(R.string.unlink, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show() + } + + private fun toggleLink() { + if (onGoogleDriveLinkToggleRequest()) { // Unlinked + updateGoogleDriveLinkUnlinkButton() + } // Else - Linking process started - button should stay the same. + } + + /** + * Toggle Google Drive link. Link to Google Drive or unlink from it, depending on current state. + * + * @return true if there was a change (Google Drive has been unlinked). + */ + private fun onGoogleDriveLinkToggleRequest(): Boolean { + return if (isGoogleDriveLinked()) { + unlinkGoogleDrive() + showSnackbar(R.string.message_google_drive_unlinked) + true + + } else { + linkGoogleDrive() + false + } + } + + private fun updateGoogleDriveLinkUnlinkButton() { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val resources = styledAttributes(R.styleable.Icons) { typedArray -> + if (isGoogleDriveLinked()) { + Pair( + getString(R.string.repo_google_drive_button_linked), + typedArray.getResourceId(R.styleable.Icons_oic_google_drive_linked, 0)) + } else { + Pair( + getString(R.string.repo_google_drive_button_not_linked), + typedArray.getResourceId(R.styleable.Icons_oic_google_drive_not_linked, 0)) + } + } + + binding.activityRepoGoogleDriveLinkButton.text = resources.first + + if (resources.second != 0) { + binding.activityRepoGoogleDriveIcon.setImageResource(resources.second) + } + } + + private fun isGoogleDriveLinked(): Boolean { + // Check for existing Google Sign In account, if the user is already signed in + // the GoogleSignInAccount will be non-null. + return GoogleSignIn.getLastSignedInAccount(this) != null + } + + private fun linkGoogleDrive() { + startActivityForResult(gsiClient.getSignInIntent(), REQUEST_CODE_SIGN_IN) + } + + private fun unlinkGoogleDrive() { + gsiClient.revokeAccess() + .addOnCompleteListener(this, OnCompleteListener { + fun onComplete(@NonNull task:Task) { + Log.d(TAG, "Signed out") + } + }) + } + + companion object { + private val TAG: String = GoogleDriveRepoActivity::class.java.name + + private const val ARG_REPO_ID = "repo_id" + + @JvmStatic + @JvmOverloads + fun start(activity: Activity, repoId: Long = 0) { + val intent = Intent(Intent.ACTION_VIEW) + .setClass(activity, GoogleDriveRepoActivity::class.java) + .putExtra(ARG_REPO_ID, repoId) + + activity.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/orgzly/android/ui/repos/ReposActivity.kt b/app/src/main/java/com/orgzly/android/ui/repos/ReposActivity.kt index 1b99d98c6..a97788a57 100644 --- a/app/src/main/java/com/orgzly/android/ui/repos/ReposActivity.kt +++ b/app/src/main/java/com/orgzly/android/ui/repos/ReposActivity.kt @@ -23,6 +23,7 @@ import com.orgzly.android.repos.RepoType import com.orgzly.android.ui.CommonActivity import com.orgzly.android.ui.repo.directory.DirectoryRepoActivity import com.orgzly.android.ui.repo.dropbox.DropboxRepoActivity +import com.orgzly.android.ui.repo.googledrive.GoogleDriveRepoActivity import com.orgzly.android.ui.repo.git.GitRepoActivity import com.orgzly.android.ui.repo.webdav.WebdavRepoActivity import com.orgzly.android.ui.showSnackbar @@ -105,6 +106,10 @@ class ReposActivity : CommonActivity(), AdapterView.OnItemClickListener, Activit newRepos.removeItem(R.id.repos_options_menu_item_new_dropbox) } + if (!BuildConfig.IS_GOOGLE_DRIVE_ENABLED) { + newRepos.removeItem(R.id.repos_options_menu_item_new_google_drive) + } + if (!AppPreferences.gitIsEnabled(App.getAppContext())) { newRepos.removeItem(R.id.repos_options_menu_item_new_git) } @@ -122,6 +127,10 @@ class ReposActivity : CommonActivity(), AdapterView.OnItemClickListener, Activit startRepoActivity(menuItem.itemId) } + R.id.repos_options_menu_item_new_google_drive -> { + startRepoActivity(menuItem.itemId) + } + R.id.repos_options_menu_item_new_git -> { startRepoActivity(menuItem.itemId) } @@ -156,6 +165,16 @@ class ReposActivity : CommonActivity(), AdapterView.OnItemClickListener, Activit } } + binding.activityReposGoogleDrive.let { button -> + if (BuildConfig.IS_GOOGLE_DRIVE_ENABLED) { + button.setOnClickListener { + startRepoActivity(R.id.repos_options_menu_item_new_google_drive) + } + } else { + button.visibility = View.GONE + } + } + binding.activityReposGit.let { button -> if (AppPreferences.gitIsEnabled(this)) { button.setOnClickListener { @@ -218,6 +237,11 @@ class ReposActivity : CommonActivity(), AdapterView.OnItemClickListener, Activit return } + R.id.repos_options_menu_item_new_google_drive -> { + GoogleDriveRepoActivity.start(this) + return + } + R.id.repos_options_menu_item_new_git -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { GitRepoActivity.start(this) @@ -262,6 +286,9 @@ class ReposActivity : CommonActivity(), AdapterView.OnItemClickListener, Activit RepoType.DROPBOX -> DropboxRepoActivity.start(this, repoEntity.id) + RepoType.GOOGLE_DRIVE -> + GoogleDriveRepoActivity.start(this, repoEntity.id) + RepoType.DIRECTORY -> DirectoryRepoActivity.start(this, repoEntity.id) diff --git a/app/src/main/res/drawable-hdpi/cic_google_drive_black.png b/app/src/main/res/drawable-hdpi/cic_google_drive_black.png new file mode 100644 index 000000000..6b0c3f04c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/cic_google_drive_black.png differ diff --git a/app/src/main/res/drawable-hdpi/cic_google_drive_color.png b/app/src/main/res/drawable-hdpi/cic_google_drive_color.png new file mode 100644 index 000000000..e6c65e7dc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/cic_google_drive_color.png differ diff --git a/app/src/main/res/drawable-hdpi/cic_google_drive_white.png b/app/src/main/res/drawable-hdpi/cic_google_drive_white.png new file mode 100644 index 000000000..decfdb1f4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/cic_google_drive_white.png differ diff --git a/app/src/main/res/drawable-mdpi/cic_google_drive_black.png b/app/src/main/res/drawable-mdpi/cic_google_drive_black.png new file mode 100644 index 000000000..d199d1c29 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/cic_google_drive_black.png differ diff --git a/app/src/main/res/drawable-mdpi/cic_google_drive_color.png b/app/src/main/res/drawable-mdpi/cic_google_drive_color.png new file mode 100644 index 000000000..631151494 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/cic_google_drive_color.png differ diff --git a/app/src/main/res/drawable-mdpi/cic_google_drive_white.png b/app/src/main/res/drawable-mdpi/cic_google_drive_white.png new file mode 100644 index 000000000..b576e39ec Binary files /dev/null and b/app/src/main/res/drawable-mdpi/cic_google_drive_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/cic_google_drive_black.png b/app/src/main/res/drawable-xhdpi/cic_google_drive_black.png new file mode 100644 index 000000000..67c56f1b5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/cic_google_drive_black.png differ diff --git a/app/src/main/res/drawable-xhdpi/cic_google_drive_color.png b/app/src/main/res/drawable-xhdpi/cic_google_drive_color.png new file mode 100644 index 000000000..c98e79930 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/cic_google_drive_color.png differ diff --git a/app/src/main/res/drawable-xhdpi/cic_google_drive_white.png b/app/src/main/res/drawable-xhdpi/cic_google_drive_white.png new file mode 100644 index 000000000..d6dcfece8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/cic_google_drive_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/cic_google_drive_black.png b/app/src/main/res/drawable-xxhdpi/cic_google_drive_black.png new file mode 100644 index 000000000..7ff51f1aa Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cic_google_drive_black.png differ diff --git a/app/src/main/res/drawable-xxhdpi/cic_google_drive_color.png b/app/src/main/res/drawable-xxhdpi/cic_google_drive_color.png new file mode 100644 index 000000000..f01295d64 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cic_google_drive_color.png differ diff --git a/app/src/main/res/drawable-xxhdpi/cic_google_drive_white.png b/app/src/main/res/drawable-xxhdpi/cic_google_drive_white.png new file mode 100644 index 000000000..37e084377 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cic_google_drive_white.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/cic_google_drive_black.png b/app/src/main/res/drawable-xxxhdpi/cic_google_drive_black.png new file mode 100644 index 000000000..c483dc92f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/cic_google_drive_black.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/cic_google_drive_color.png b/app/src/main/res/drawable-xxxhdpi/cic_google_drive_color.png new file mode 100644 index 000000000..ed4a57250 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/cic_google_drive_color.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/cic_google_drive_white.png b/app/src/main/res/drawable-xxxhdpi/cic_google_drive_white.png new file mode 100644 index 000000000..283285112 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/cic_google_drive_white.png differ diff --git a/app/src/main/res/layout/activity_repo_dropbox.xml b/app/src/main/res/layout/activity_repo_dropbox.xml index 890698a33..14411fb42 100644 --- a/app/src/main/res/layout/activity_repo_dropbox.xml +++ b/app/src/main/res/layout/activity_repo_dropbox.xml @@ -87,4 +87,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/activity_repo_google_drive.xml b/app/src/main/res/layout/activity_repo_google_drive.xml new file mode 100644 index 000000000..36f227684 --- /dev/null +++ b/app/src/main/res/layout/activity_repo_google_drive.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + +