diff --git a/ChangeLog.txt b/ChangeLog.txt
index 0350931cc..1ab1292fa 100644
--- a/ChangeLog.txt
+++ b/ChangeLog.txt
@@ -1,3 +1,7 @@
+3.0.1 (2017-03-29)
+---------------------------------------------
+- Add OpenWith support for official partners.
+
3.0.0 (2017-03-17)
---------------------------------------------
- Breaking changes:
diff --git a/ReadMe.md b/ReadMe.md
index 1794524cc..457428246 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -14,7 +14,7 @@ If you're using Maven, then edit your project's "pom.xml" and add this to the `<
+ * This variant should be used when authentication is being done due to an OpenWith request through action + * {@value DbxOfficialAppConnector#ACTION_DBXC_EDIT} and {@value DbxOfficialAppConnector#ACTION_DBXC_VIEW}. + * You won't need to use this unless you are an partner who registered your app with openwith feature in our official + * Dropbox app. + *
+ * + * @param context the {@link Context} which to use to launch the + * Dropbox authentication activity. This will typically be an + * {@link Activity} and the user will be taken back to that + * activity after authentication is complete (i.e., your activity + * will receive an {@code onResume()}). + * @param appKey the app's key. + * @param desiredUid Encourage user to authenticate account defined by this uid. + * (note that user still can authenticate other accounts). + * May be null if no uid desired. + * @param alreadyAuthedUids Array of any other uids currently authenticated with this app. + * May be null if no uids previously authenticated. + * Authentication screen will encourage user to not authorize these + * user accounts. (note that user may still authorize the accounts). + * @param sessionId The SESSION_ID Extra on an OpenWith intent. null if dAuth + * is being launched outside of OpenWith flow + * @throws IllegalStateException if you have not correctly set up the AuthActivity in your + * manifest, meaning that the Dropbox app will + * not be able to redirect back to your app after auth. + */ + public static void startOAuth2Authentication(Context context, String appKey, String desiredUid, + String[] alreadyAuthedUids, String sessionId) { if (!AuthActivity.checkAppBeforeAuth(context, appKey, true /*alertUser*/)) { return; } + if (alreadyAuthedUids != null && Arrays.asList(alreadyAuthedUids).contains(desiredUid)) { + throw new IllegalArgumentException("desiredUid cannot be present in alreadyAuthedUids"); + } + // Start Dropbox auth activity. String apiType = "1"; String webHost = "www.dropbox.com"; - Intent intent = AuthActivity.makeIntent(context, appKey, webHost, apiType); + Intent intent = AuthActivity.makeIntent( + context, appKey, desiredUid, alreadyAuthedUids, sessionId, webHost, apiType + ); if (!(context instanceof Activity)) { // If starting the intent outside of an Activity, must include // this. See startActivity(). Otherwise, we prefer to stay in @@ -48,4 +90,23 @@ public static String getOAuth2Token() { return null; } + public static String getUid() { + Intent data = AuthActivity.result; + + if (data == null) { + return null; + } + + String token = data.getStringExtra(AuthActivity.EXTRA_ACCESS_TOKEN); + String secret = data.getStringExtra(AuthActivity.EXTRA_ACCESS_SECRET); + String uid = data.getStringExtra(AuthActivity.EXTRA_UID); + + if (token != null && !token.equals("") && + secret != null && !secret.equals("") && + uid != null && !uid.equals("")) { + return uid; + } + + return null; + } } diff --git a/src/main/java/com/dropbox/core/android/AuthActivity.java b/src/main/java/com/dropbox/core/android/AuthActivity.java index 73342823e..5eaa1f588 100644 --- a/src/main/java/com/dropbox/core/android/AuthActivity.java +++ b/src/main/java/com/dropbox/core/android/AuthActivity.java @@ -1,7 +1,6 @@ package com.dropbox.core.android; import java.security.SecureRandom; -import java.util.Arrays; import java.util.List; import java.util.Locale; @@ -12,11 +11,8 @@ import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; import android.content.Intent; -import android.content.pm.PackageInfo; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; -import android.content.pm.Signature; import android.net.Uri; import android.os.Bundle; import android.os.Handler; @@ -97,6 +93,12 @@ public class AuthActivity extends Activity { */ public static final String EXTRA_ALREADY_AUTHED_UIDS = "ALREADY_AUTHED_UIDS"; + /** + * Used for internal authentication. Allows app to transfer session info to/from DbApp + * You won't ever have to use this. + */ + public static final String EXTRA_SESSION_ID = "SESSION_ID"; + /** * The Android action which the official Dropbox app will accept to * authenticate a user. You won't ever have to use this. @@ -158,6 +160,7 @@ public SecureRandom getSecureRandom() { private static String sApiType; private static String sDesiredUid; private static String[] sAlreadyAuthedUids; + private static String sSessionId; // These instance variables need not be stored in savedInstanceState as onNewIntent() // does not read them. @@ -166,6 +169,7 @@ public SecureRandom getSecureRandom() { private String mApiType; private String mDesiredUid; private String[] mAlreadyAuthedUids; + private String mSessionId; // Stored in savedInstanceState to track an ongoing auth attempt, which // must include a locally-generated nonce in the response. @@ -178,17 +182,36 @@ public SecureRandom getSecureRandom() { */ static void setAuthParams(String appKey, String desiredUid, String[] alreadyAuthedUids) { - setAuthParams(appKey, desiredUid, alreadyAuthedUids, null, null); + setAuthParams(appKey, desiredUid, alreadyAuthedUids, null); } + + /** * Set static authentication parameters */ static void setAuthParams(String appKey, String desiredUid, String[] alreadyAuthedUids, String webHost, String apiType) { + setAuthParams(appKey, desiredUid, alreadyAuthedUids, null, null, null); + } + + /** + * Set static authentication parameters + */ + static void setAuthParams(String appKey, String desiredUid, + String[] alreadyAuthedUids, String sessionId) { + setAuthParams(appKey, desiredUid, alreadyAuthedUids, sessionId, null, null); + } + + /** + * Set static authentication parameters + */ + static void setAuthParams(String appKey, String desiredUid, + String[] alreadyAuthedUids, String sessionId, String webHost, String apiType) { sAppKey = appKey; sDesiredUid = desiredUid; sAlreadyAuthedUids = (alreadyAuthedUids != null) ? alreadyAuthedUids : new String[0]; + sSessionId = sessionId; sWebHost = (webHost != null) ? webHost : DEFAULT_WEB_HOST; sApiType = apiType; } @@ -206,8 +229,33 @@ static void setAuthParams(String appKey, String desiredUid, */ public static Intent makeIntent(Context context, String appKey, String webHost, String apiType) { + return makeIntent(context, appKey, null, null, null, webHost, apiType); + } + + /** + * Create an intent which can be sent to this activity to start OAuth 2 authentication. + * + * @param context the source context + * @param appKey the consumer key for the app + * @param desiredUid Encourage user to authenticate account defined by this uid. + * (note that user still can authenticate other accounts). + * May be null if no uid desired. + * @param alreadyAuthedUids Array of any other uids currently authenticated with this app. + * May be null if no uids previously authenticated. + * Authentication screen will encourage user to not authorize these + * user accounts. (note that user may still authorize the accounts). + * @param sessionId The SESSION_ID Extra on an OpenWith intent. null if dAuth + * is being launched outside of OpenWith flow + * @param webHost the host to use for web authentication, or null for the default + * @param apiType an identifier for the type of API being supported, or null for + * the default + * + * @return a newly created intent. + */ + public static Intent makeIntent(Context context, String appKey, String desiredUid, String[] alreadyAuthedUids, + String sessionId, String webHost, String apiType) { if (appKey == null) throw new IllegalArgumentException("'appKey' can't be null"); - setAuthParams(appKey, null, null, webHost, apiType); + setAuthParams(appKey, desiredUid, alreadyAuthedUids, sessionId, webHost, apiType); return new Intent(context, AuthActivity.class); } @@ -321,6 +369,7 @@ protected void onCreate(Bundle savedInstanceState) { mApiType = sApiType; mDesiredUid = sDesiredUid; mAlreadyAuthedUids = sAlreadyAuthedUids; + mSessionId = sSessionId; if (savedInstanceState == null) { result = null; @@ -344,7 +393,7 @@ protected void onSaveInstanceState(Bundle outState) { * @return Intent to auth with official app * Extras should be filled in by callee */ - private static Intent getOfficialAuthIntent() { + static Intent getOfficialAuthIntent() { Intent authIntent = new Intent(ACTION_AUTHENTICATE_V2); authIntent.setPackage("com.dropbox.android"); return authIntent; @@ -387,6 +436,7 @@ protected void onResume() { officialAuthIntent.putExtra(EXTRA_CONSUMER_SIG, ""); officialAuthIntent.putExtra(EXTRA_DESIRED_UID, mDesiredUid); officialAuthIntent.putExtra(EXTRA_ALREADY_AUTHED_UIDS, mAlreadyAuthedUids); + officialAuthIntent.putExtra(EXTRA_SESSION_ID, mSessionId); officialAuthIntent.putExtra(EXTRA_CALLING_PACKAGE, getPackageName()); officialAuthIntent.putExtra(EXTRA_CALLING_CLASS, getClass().getName()); officialAuthIntent.putExtra(EXTRA_AUTH_STATE, state); @@ -404,7 +454,7 @@ public void run() { Log.d(TAG, "running startActivity in handler"); try { // Auth with official app, or fall back to web. - if (hasDropboxApp(officialAuthIntent)) { + if (DbxOfficialAppConnector.getDropboxAppPackage(AuthActivity.this, officialAuthIntent) != null) { startActivity(officialAuthIntent); } else { startWebAuth(state); @@ -489,41 +539,6 @@ private void authFinished(Intent authResult) { finish(); } - private boolean hasDropboxApp(Intent intent) { - PackageManager manager = getPackageManager(); - - Listuid
is empty
+ */
+ public DbxOfficialAppConnector(String uid) throws DropboxUidNotInitializedException {
+ if (uid == null || uid.length() == 0) {
+ throw new DropboxUidNotInitializedException(
+ "Must initialize session's uid before constructing DbxOfficialAppConnector");
+ }
+ this.uid = uid;
+ }
+
+ /**
+ * Add uid information to an explicit intent directed to DropboxApp
+ */
+ protected void addExtrasToIntent(Context context, Intent intent) {
+ intent.putExtra(EXTRA_DROPBOX_UID, uid);
+ intent.putExtra(EXTRA_CALLING_PACKAGE, context.getPackageName());
+ }
+
+ /**
+ * @return Information about installed version of DropboxApp. Returns null if DropboxApp is not
+ * installed
+ */
+ public static DbxOfficialAppInstallInfo isInstalled(Context context) {
+
+ // For now, use dAuth intent
+ Intent authIntent = AuthActivity.getOfficialAuthIntent();
+ PackageInfo dropboxPackage = getDropboxAppPackage(context, authIntent);
+ if (dropboxPackage == null) {
+ return null;
+ }
+
+ int versionCode = dropboxPackage.versionCode;
+ boolean supportsOpenWith = versionCode >= MIN_OPENWITH_VERSION;
+ return new DbxOfficialAppInstallInfo(supportsOpenWith, versionCode);
+ }
+
+ private static final Uri LOGGED_IN_URI = Uri
+ .parse("content://com.dropbox.android.provider.SDK/is_user_logged_in/");
+ private static final int CORRECT_USER = 1;
+ private static final int NO_USER = 0;
+ private static final int WRONG_USER = -1;
+
+ /**
+ * Determine if user uid is logged in
+ *
+ * @param context
+ * @param uid
+ * @return NO_USER if no users connected CORRECT_USER if uid connected WRONG_USER if uid not
+ * connected
+ */
+ private static int getLoggedinState(Context context, String uid) {
+ Cursor cursor = context.getContentResolver().query(
+ LOGGED_IN_URI.buildUpon().appendPath(uid).build(), null, // projection
+ null, // selection clause
+ null, // selection args
+ null); // sort order
+
+ if (cursor == null) {
+ // DropboxApp not installed
+ return NO_USER;
+ }
+ cursor.moveToFirst();
+ return cursor.getInt(cursor.getColumnIndex("logged_in"));
+ }
+
+ /**
+ * @return If any account is connected to DropboxApp
+ */
+ public static boolean isAnySignedIn(Context context) {
+ int loggedInState = getLoggedinState(context, "0");
+ return loggedInState != NO_USER;
+ }
+
+ /**
+ * @return Intent that when passed into startActivity() will launch the Play Store page for
+ * Dropbox.
+ */
+ public static Intent getDropboxPlayStoreIntent() {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse("market://details?id=com.dropbox.android"));
+ return intent;
+ }
+
+ /**
+ * @return If authorized user is signed in to DropboxApp
+ */
+ public boolean isSignedIn(Context context) {
+ int loggedInState = getLoggedinState(context, uid);
+ return loggedInState == CORRECT_USER;
+ }
+
+ protected Intent launchDropbox(Context context) {
+ PackageManager pm = context.getPackageManager();
+ Intent i = pm.getLaunchIntentForPackage("com.dropbox.android");
+ if (getDropboxAppPackage(context, i) == null) {
+ return null;
+ }
+ return i;
+ }
+
+ /**
+ * @return Intent that when passed into startActivity() will start Dropbox account upgrade flow.
+ * If DropboxApp is installed, upgrade flow will launch an activity within DropboxApp.
+ * Otherwise, a web browser will be launched
+ */
+ public Intent getUpgradeAccountIntent(Context context) {
+ Intent upgradeIntent = new Intent(ACTION_SHOW_UPGRADE);
+ addExtrasToIntent(context, upgradeIntent);
+
+ if (getDropboxAppPackage(context, upgradeIntent) != null) {
+ return upgradeIntent;
+ }
+ // Fall back to web upgrade if no Dropbox App Installed
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse("https://www.dropbox.com/upgrade?oqa=upeaoq"));
+ return intent;
+ }
+
+ /* Begin internal functions */
+
+ private static final String[] DROPBOX_APP_SIGNATURES = {
+ "308202223082018b02044bd207bd300d06092a864886f70d01010405003058310b3"
+ + "009060355040613025553310b300906035504081302434131163014060355040713"
+ + "0d53616e204672616e636973636f3110300e060355040a130744726f70626f78311"
+ + "2301006035504031309546f6d204d65796572301e170d3130303432333230343930"
+ + "315a170d3430303431353230343930315a3058310b3009060355040613025553310"
+ + "b3009060355040813024341311630140603550407130d53616e204672616e636973"
+ + "636f3110300e060355040a130744726f70626f783112301006035504031309546f6"
+ + "d204d6579657230819f300d06092a864886f70d010101050003818d003081890281"
+ + "8100ac1595d0ab278a9577f0ca5a14144f96eccde75f5616f36172c562fab0e98c4"
+ + "8ad7d64f1091c6cc11ce084a4313d522f899378d312e112a748827545146a779def"
+ + "a7c31d8c00c2ed73135802f6952f59798579859e0214d4e9c0554b53b26032a4d2d"
+ + "fc2f62540d776df2ea70e2a6152945fb53fef5bac5344251595b729d48102030100"
+ + "01300d06092a864886f70d01010405000381810055c425d94d036153203dc0bbeb3"
+ + "516f94563b102fff39c3d4ed91278db24fc4424a244c2e59f03bbfea59404512b8b"
+ + "f74662f2a32e37eafa2ac904c31f99cfc21c9ff375c977c432d3b6ec22776f28767"
+ + "d0f292144884538c3d5669b568e4254e4ed75d9054f75229ac9d4ccd0b7c3c74a34"
+ + "f07b7657083b2aa76225c0c56ffc",
+ "308201e53082014ea00302010202044e17e115300d06092a864886f70d010105050"
+ + "03037310b30090603550406130255533110300e060355040a1307416e64726f6964"
+ + "311630140603550403130d416e64726f6964204465627567301e170d31313037303"
+ + "93035303331375a170d3431303730313035303331375a3037310b30090603550406"
+ + "130255533110300e060355040a1307416e64726f6964311630140603550403130d4"
+ + "16e64726f696420446562756730819f300d06092a864886f70d010101050003818d"
+ + "003081890281810096759fe5abea6a0757039b92adc68d672efa84732c3f959408e"
+ + "12efa264545c61f23141026a6d01eceeeaa13ec7087087e5894a3363da8bf5c69ed"
+ + "93657a6890738a80998e4ca22dc94848f30e2d0e1890000ae2cddf543b20c0c3828"
+ + "deca6c7944b5ecd21a9d18c988b2b3e54517dafbc34b48e801bb1321e0fa49e4d57"
+ + "5d7f0203010001300d06092a864886f70d0101050500038181002b6d4b65bcfa6ec"
+ + "7bac97ae6d878064d47b3f9f8da654995b8ef4c385bc4fbfbb7a987f60783ef0348"
+ + "760c0708acd4b7e63f0235c35a4fbcd5ec41b3b4cb295feaa7d5c27fa562a02562b"
+ + "7e1f4776b85147be3e295714986c4a9a07183f48ea09ae4d3ea31b88d0016c65b93"
+ + "526b9c45f2967c3d28dee1aff5a5b29b9c2c8639"};
+
+ /**
+ * Verify that intent will be processed by Dropbox App
+ *
+ * @return PackageInfo of DropboxApp if Dropbox App can process intent, else null
+ */
+ static PackageInfo getDropboxAppPackage(Context context, Intent intent) {
+ PackageManager manager = context.getPackageManager();
+
+ ListYou won't need to use this unless you are our official partner in openwith.
+ * + * @param path path of file in authorized user's Dropbox to preview + * @param lastRev The revision of file user is seeing (as returned by + * DropboxAPI.getFile/DropboxAPI.putFile) + * @return Intent that when passed into startActivity() displays Dropbox preview Returns null if + * DropboxApp is not installed + */ + public Intent getPreviewFileIntent(Context context, String path, String lastRev) { + // TODO(jiuyangzhao): Assert path is valid + Intent previewIntent = new Intent(ACTION_SHOW_DROPBOX_PREVIEW); + addExtrasToIntent(context, previewIntent); + previewIntent.putExtra(EXTRA_DROPBOX_PATH, path); + previewIntent.putExtra(EXTRA_DROPBOX_REV, lastRev); + + if (getDropboxAppPackage(context, previewIntent) == null) { + return null; + } + return previewIntent; + } + + /** + * Decodes a Google Play Campaign attribution utm_content field that was generated by Dropbox + * OpenWith flow. This should only be called if utm_source=”dropbox_android_openwith”. See + * https://developers.google.com/analytics/devguides/collection/android/v4/campaign for more + * information about how to use Play Store attribution. + * + *You won't need to use this unless you are our official partner in openwith.
+ * + * @param UtmContent GooglePlay utm content that has been urldecoded + * @return Intent OpenWith intent that, when launched, will open the file the user requested to + * edit. Caller MUST convert intent into an explicit intent it can handle. + * @throws DropboxParseException if cannot produce Intent from UtmContent + */ + + public static Intent generateOpenWithIntentFromUtmContent(String UtmContent) + throws DropboxParseException { + // Utm content is encoded a base64-encoded marshalled bundle + // _action is extracted and becomes intent's action + // _uri is extracted and becomes intent's data uri + // All other items in bundle transferred to returned intent's extras + + byte[] b; + try { + b = Base64.decode(UtmContent, 0); + } catch (IllegalArgumentException ex) { + throw new DropboxParseException("UtmContent was not base64 encoded: " + ex.getMessage()); + } + + final Parcel parcel = Parcel.obtain(); + parcel.unmarshall(b, 0, b.length); + parcel.setDataPosition(0); + Bundle bundle = parcel.readBundle(); + parcel.recycle(); + + if (bundle == null) { + throw new DropboxParseException("Could not extract bundle from UtmContent"); + } + + String action = bundle.getString("_action"); + if (action == null) { + throw new DropboxParseException("_action was not present in bundle"); + } + bundle.remove("_action"); + + Uri uri = bundle.getParcelable("_uri"); + if (uri == null) { + throw new DropboxParseException("_uri was not present in bundle"); + } + bundle.remove("_uri"); + + String type = bundle.getString("_type"); + if (type == null) { + throw new DropboxParseException("_type was not present in bundle"); + } + bundle.remove("_type"); + + Intent openWithIntent = new Intent(action); + openWithIntent.setDataAndType(uri, type); + openWithIntent.putExtras(bundle); + + return openWithIntent; + } +} diff --git a/src/main/java/com/dropbox/core/android/DropboxParseException.java b/src/main/java/com/dropbox/core/android/DropboxParseException.java new file mode 100644 index 000000000..f1781bbd5 --- /dev/null +++ b/src/main/java/com/dropbox/core/android/DropboxParseException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2009-2017 Dropbox, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.dropbox.core.android; + +import com.dropbox.core.DbxException; + +/** + * Thrown when {@link DbxOfficialAppConnector} can't parse the utm content. + */ +public class DropboxParseException extends DbxException { + private static final long serialVersionUID = 1L; + + public DropboxParseException(String message) { + super(message); + } +} diff --git a/src/main/java/com/dropbox/core/android/DropboxUidNotInitializedException.java b/src/main/java/com/dropbox/core/android/DropboxUidNotInitializedException.java new file mode 100644 index 000000000..ed4d92cd2 --- /dev/null +++ b/src/main/java/com/dropbox/core/android/DropboxUidNotInitializedException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2009-2017 Dropbox, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.dropbox.core.android; + +import com.dropbox.core.DbxException; + +/** + * Thrown when {@link DbxOfficialAppConnector} is initialized with an empty uid. + */ +public class DropboxUidNotInitializedException extends DbxException { + private static final long serialVersionUID = 1L; + + public DropboxUidNotInitializedException(String message) { + super(message); + } +}