syncslides: Port account and blessing initialization.
V23.java is heavily inspired by the old V23Manager.java. I stripped out
anything related to mounttables, and I simplified some of the code.
SignInActivity and related files are a copy/paste from the other project.
This activity requires a few more permissions and a min sdk of 22.
Also, renamed the app to DevSyncSlides so I can retain the demo-ready
version while I do development on this version.
Change-Id: Ia7a468a76c7df492e8f3d36f8528c48f8299c159
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 4e5b9ac..da166ab 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -18,7 +18,7 @@
defaultConfig {
applicationId "io.v.syncslides"
- minSdkVersion 19
+ minSdkVersion 22
targetSdkVersion 23
versionCode 1
versionName "1.0"
@@ -40,4 +40,5 @@
testCompile 'junit:junit:4.12'
compile 'com.android.support:appcompat-v7:23.1.0'
compile 'com.android.support:design:23.1.0'
+ compile project(':android-lib')
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7d6ac9d..f30168e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,22 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
- package="io.v.syncslides" >
+<manifest
+ package="io.v.syncslides"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-sdk android:minSdkVersion="22"/>
+
+ <!-- SignInActivity has the user select an email address. -->
+ <uses-permission android:name="android.permission.GET_ACCOUNTS"/>
+ <!-- SignInActivity uses this as a simple way to get the user's name -->
+ <!-- without an internet connection. -->
+ <uses-permission android:name="android.permission.READ_CONTACTS"/>
+ <uses-permission android:name="android.permission.READ_PROFILE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
- android:theme="@style/AppTheme" >
+ android:theme="@style/AppTheme">
+ <activity
+ android:name=".SignInActivity"
+ android:label="@string/app_name">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+
<activity
android:name=".MainActivity"
android:label="@string/app_name"
- android:theme="@style/AppTheme.NoActionBar" >
- <intent-filter>
- <action android:name="android.intent.action.MAIN" />
-
- <category android:name="android.intent.category.LAUNCHER" />
- </intent-filter>
+ android:theme="@style/AppTheme.NoActionBar">
</activity>
</application>
diff --git a/android/app/src/main/java/io/v/syncslides/MainActivity.java b/android/app/src/main/java/io/v/syncslides/MainActivity.java
index bcf663e..fc3872d 100644
--- a/android/app/src/main/java/io/v/syncslides/MainActivity.java
+++ b/android/app/src/main/java/io/v/syncslides/MainActivity.java
@@ -4,20 +4,29 @@
package io.v.syncslides;
+import android.content.Intent;
import android.os.Bundle;
import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
+import android.util.Log;
import android.view.View;
import android.view.Menu;
import android.view.MenuItem;
public class MainActivity extends AppCompatActivity {
+ private static final String TAG = "MainActivity";
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+
+ // Immediately initialize V23, possibly sending user to the
+ // AccountManager to get blessings.
+ V23.Singleton.get().init(getApplicationContext(), this);
+
setContentView(R.layout.activity_main);
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
@@ -53,4 +62,17 @@
return super.onOptionsItemSelected(item);
}
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ Log.d(TAG, "onActivityResult");
+ if (V23.Singleton.get().onActivityResult(
+ getApplicationContext(), requestCode, resultCode, data)) {
+ Log.d(TAG, "did the v23 result");
+ return;
+ }
+ // Any other activity results would be handled here.
+ }
+
}
diff --git a/android/app/src/main/java/io/v/syncslides/SignInActivity.java b/android/app/src/main/java/io/v/syncslides/SignInActivity.java
new file mode 100644
index 0000000..0d8408d
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/SignInActivity.java
@@ -0,0 +1,307 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncslides;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AccountManagerCallback;
+import android.accounts.AccountManagerFuture;
+import android.accounts.AuthenticatorException;
+import android.accounts.OperationCanceledException;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.os.AsyncTask;
+import android.os.Handler;
+import android.os.Message;
+import android.preference.PreferenceManager;
+import android.provider.ContactsContract;
+import android.support.v7.app.AppCompatActivity;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import com.google.common.base.Charsets;
+import com.google.common.io.CharStreams;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+/**
+ * Signs in the user into one of his Gmail accounts.
+ */
+public class SignInActivity extends AppCompatActivity {
+ private static final String TAG = "SignInActivity";
+
+ private static final String PREF_USER_ACCOUNT_NAME = "user_account";
+ private static final String PREF_USER_NAME_FROM_CONTACTS = "user_name_from_contacts";
+ private static final String PREF_USER_NAME_FROM_PROFILE = "user_name_from_profile";
+ private static final String PREF_USER_PROFILE_JSON = "user_profile";
+
+ private static final int REQUEST_CODE_PICK_ACCOUNT = 1000;
+ private static final int REQUEST_CODE_FETCH_USER_PROFILE_APPROVAL = 1001;
+
+ private static final String OAUTH_PROFILE = "email";
+ private static final String OAUTH_SCOPE = "oauth2:" + OAUTH_PROFILE;
+ private static final String OAUTH_USERINFO_URL =
+ "https://www.googleapis.com/oauth2/v2/userinfo";
+
+ private SharedPreferences mPrefs;
+ private String mAccountName;
+ private ProgressDialog mProgressDialog;
+
+ /**
+ * Returns the best-effort email of the signed-in user.
+ */
+ public static String getUserEmail(Context ctx) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx);
+ return prefs.getString(PREF_USER_ACCOUNT_NAME, "");
+ }
+
+ /**
+ * Returns the best-effort full name of the signed-in user.
+ */
+ public static String getUserName(Context ctx) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx);
+ // First try to read the user name we obtained from profile, as it's most accurate.
+ if (prefs.contains(PREF_USER_NAME_FROM_PROFILE)) {
+ return prefs.getString(PREF_USER_NAME_FROM_PROFILE, "Anonymous User");
+ }
+ return prefs.getString(PREF_USER_NAME_FROM_CONTACTS, "Anonymous User");
+ }
+
+ /**
+ * Returns the Google profile information of the signed-in user, or {@code null} if the
+ * profile information couldn't be retrieved.
+ */
+ public static JSONObject getUserProfile(Context ctx) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx);
+ String userProfileJsonStr = prefs.getString(PREF_USER_PROFILE_JSON, "");
+ if (!userProfileJsonStr.isEmpty()) {
+ try {
+ return new JSONObject(userProfileJsonStr);
+ } catch (JSONException e) {
+ Log.e(TAG, "Couldn't parse user profile data: " + userProfileJsonStr);
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_sign_in);
+ mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
+ mAccountName = mPrefs.getString(SignInActivity.PREF_USER_ACCOUNT_NAME, "");
+ mProgressDialog = new ProgressDialog(this);
+ if (mAccountName.isEmpty()) {
+ mProgressDialog.setMessage("Signing in...");
+ mProgressDialog.show();
+ pickAccount();
+ } else {
+ finishActivity();
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ switch (requestCode) {
+ case REQUEST_CODE_PICK_ACCOUNT: {
+ if (resultCode != RESULT_OK) {
+ Toast.makeText(this, "Must pick account", Toast.LENGTH_LONG).show();
+ pickAccount();
+ break;
+ }
+ pickAccountDone(data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME));
+ break;
+ }
+ case REQUEST_CODE_FETCH_USER_PROFILE_APPROVAL:
+ if (resultCode != RESULT_OK) {
+ Log.e(TAG, "User didn't approve oauth2 request");
+ break;
+ }
+ fetchUserProfile();
+ break;
+ }
+ }
+
+ private void pickAccount() {
+ Intent chooseIntent = AccountManager.newChooseAccountIntent(
+ null, null, new String[]{"com.google"}, false, null, null, null, null);
+ startActivityForResult(chooseIntent, REQUEST_CODE_PICK_ACCOUNT);
+ }
+
+ private void pickAccountDone(String accountName) {
+ mAccountName = accountName;
+ SharedPreferences.Editor editor = mPrefs.edit();
+ editor.putString(PREF_USER_ACCOUNT_NAME, accountName);
+ editor.commit();
+
+ fetchUserNameFromContacts();
+
+ // NOTE(spetrovic): For demo purposes, fetching user profile is too risky as it requires
+ // internet access. So we disable it for now.
+ //fetchUserProfile();
+ finishActivity();
+ }
+
+ private void fetchUserNameFromContacts() {
+ // Get the user's full name from Contacts.
+ Cursor c = getContentResolver().query(ContactsContract.Profile.CONTENT_URI,
+ null, null, null, null);
+ String[] columnNames = c.getColumnNames();
+ String userName = "Anonymous User";
+ while (c.moveToNext()) {
+ for (int j = 0; j < columnNames.length; j++) {
+ String columnName = columnNames[j];
+ if (!columnName.equals(ContactsContract.Contacts.DISPLAY_NAME)) {
+ continue;
+ }
+ userName = c.getString(c.getColumnIndex(columnName));
+ }
+ }
+ c.close();
+ SharedPreferences.Editor editor = mPrefs.edit();
+ editor.putString(PREF_USER_NAME_FROM_CONTACTS, userName);
+ editor.commit();
+ }
+
+ private void fetchUserProfile() {
+ AccountManager manager = (AccountManager) getSystemService(Context.ACCOUNT_SERVICE);
+ Account[] accounts = manager.getAccountsByType("com.google");
+ Account account = null;
+ for (int i = 0; i < accounts.length; i++) {
+ if (accounts[i].name.equals(mAccountName)) {
+ account = accounts[i];
+ break;
+ }
+ }
+ if (account == null) {
+ Log.e(TAG, "Couldn't find Google account with name: " + mAccountName);
+ pickAccount();
+ return;
+ }
+ manager.getAuthToken(account,
+ OAUTH_SCOPE,
+ new Bundle(),
+ false,
+ new OnTokenAcquired(),
+ new Handler(new Handler.Callback() {
+ @Override
+ public boolean handleMessage(Message msg) {
+ Log.e(TAG, "Error getting auth token: " + msg.toString());
+ fetchUserProfileDone(null);
+ return true;
+ }
+ }));
+ }
+
+ private void fetchUserProfileDone(JSONObject userProfile) {
+ if (userProfile != null) {
+ SharedPreferences.Editor editor = mPrefs.edit();
+ editor.putString(PREF_USER_PROFILE_JSON, userProfile.toString());
+ try {
+ if (userProfile.has("name") && !userProfile.getString("name").isEmpty()) {
+ editor.putString(PREF_USER_NAME_FROM_PROFILE, userProfile.getString("name"));
+ }
+ } catch (JSONException e) {
+ Log.e(TAG, "Couldn't read user name from user profile: " + e.getMessage());
+ }
+ editor.commit();
+ }
+ finishActivity();
+ }
+
+
+ private void finishActivity() {
+ mProgressDialog.dismiss();
+ startActivity(new Intent(this, MainActivity.class));
+ finish();
+ }
+
+ private class OnTokenAcquired implements AccountManagerCallback<Bundle> {
+ @Override
+ public void run(AccountManagerFuture<Bundle> result) {
+ try {
+ Bundle bundle = result.getResult();
+ Intent launch = (Intent) bundle.get(AccountManager.KEY_INTENT);
+ if (launch != null) { // Needs user approval.
+ // NOTE(spetrovic): The returned intent has the wrong flag value
+ // FLAG_ACTIVITY_NEW_TASK set, which results in the launched intent replying
+ // immediately with RESULT_CANCELED. Hence, we clear the flag here.
+ launch.setFlags(0);
+ startActivityForResult(launch, REQUEST_CODE_FETCH_USER_PROFILE_APPROVAL);
+ return;
+ }
+ String token = bundle.getString(AccountManager.KEY_AUTHTOKEN);
+ new ProfileInfoFetcher().execute(token);
+ } catch (AuthenticatorException e) {
+ Log.e(TAG, "Couldn't authorize: " + e.getMessage());
+ fetchUserProfileDone(null);
+ } catch (OperationCanceledException e) {
+ Log.e(TAG, "Authorization cancelled: " + e.getMessage());
+ fetchUserProfileDone(null);
+ } catch (IOException e) {
+ Log.e(TAG, "Unexpected error: " + e.getMessage());
+ fetchUserProfileDone(null);
+ }
+ }
+ }
+
+ private class ProfileInfoFetcher extends AsyncTask<String, Void, JSONObject> {
+ @Override
+ protected JSONObject doInBackground(String... params) {
+ try {
+ URL url = new URL(OAUTH_USERINFO_URL + "?access_token=" + params[0]);
+ return new JSONObject(CharStreams.toString(
+ new InputStreamReader(url.openConnection().getInputStream(),
+ Charsets.US_ASCII)));
+ } catch (MalformedURLException e) {
+ Log.e(TAG, "Error fetching user's profile info" + e.getMessage());
+ } catch (JSONException e) {
+ Log.e(TAG, "Error fetching user's profile info" + e.getMessage());
+ } catch (IOException e) {
+ Log.e(TAG, "Error fetching user's profile info" + e.getMessage());
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(JSONObject userProfile) {
+ fetchUserProfileDone(userProfile);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ // Inflate the menu; this adds items to the action bar if it is present.
+ getMenuInflater().inflate(R.menu.menu_sign_in, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ // Handle action bar item clicks here. The action bar will
+ // automatically handle clicks on the Home/Up button, so long
+ // as you specify a parent activity in AndroidManifest.xml.
+ int id = item.getItemId();
+
+ //noinspection SimplifiableIfStatement
+ if (id == R.id.action_settings) {
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/V23.java b/android/app/src/main/java/io/v/syncslides/V23.java
new file mode 100644
index 0000000..1a8ab37
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/V23.java
@@ -0,0 +1,127 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncslides;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import io.v.android.libs.security.BlessingsManager;
+import io.v.android.v23.V;
+import io.v.android.v23.services.blessing.BlessingCreationException;
+import io.v.android.v23.services.blessing.BlessingService;
+import io.v.v23.context.VContext;
+import io.v.v23.security.BlessingPattern;
+import io.v.v23.security.Blessings;
+import io.v.v23.security.VPrincipal;
+import io.v.v23.security.VSecurity;
+import io.v.v23.verror.VException;
+import io.v.v23.vom.VomUtil;
+
+/**
+ * Handles Vanadium initialization.
+ */
+public class V23 {
+
+ private static final String TAG = "V23";
+ private static final int BLESSING_REQUEST = 201;
+ private Context mContext;
+ private VContext mVContext;
+ private Blessings mBlessings = null;
+
+ public static class Singleton {
+ private static volatile V23 instance;
+
+ public static V23 get() {
+ V23 result = instance;
+ if (instance == null) {
+ synchronized (Singleton.class) {
+ result = instance;
+ if (result == null) {
+ instance = result = new V23();
+ }
+ }
+ }
+ return result;
+ }
+ }
+
+ // Singleton.
+ private V23() {
+ }
+
+ public void init(Context context, Activity activity) {
+ if (mBlessings != null) {
+ return;
+ }
+ mContext = context;
+ mVContext = V.init(mContext);
+ Blessings blessings = loadBlessings();
+ if (blessings == null) {
+ // Get the signed-in user's email to generate the blessings from.
+ String userEmail = SignInActivity.getUserEmail(mContext);
+ activity.startActivityForResult(
+ BlessingService.newBlessingIntent(mContext, userEmail), BLESSING_REQUEST);
+ return;
+ }
+ configurePrincipal(blessings);
+ }
+
+ /**
+ * To be called from an Activity's onActivityResult method, e.g.
+ * public void onActivityResult(
+ * int requestCode, int resultCode, Intent data) {
+ * if (V23.Singleton.get().onActivityResult(
+ * getApplicationContext(), requestCode, resultCode, data)) {
+ * return;
+ * }
+ * }
+ */
+ public boolean onActivityResult(
+ Context context, int requestCode, int resultCode, Intent data) {
+ if (requestCode != BLESSING_REQUEST) {
+ return false;
+ }
+ try {
+ Log.d(TAG, "unpacking blessing");
+ byte[] blessingsVom = BlessingService.extractBlessingReply(resultCode, data);
+ Blessings blessings = (Blessings) VomUtil.decode(blessingsVom, Blessings.class);
+ BlessingsManager.addBlessings(mContext, blessings);
+ configurePrincipal(blessings);
+// DB.Singleton.get(androidCtx).init();
+ } catch (BlessingCreationException e) {
+ throw new IllegalStateException(e);
+ } catch (VException e) {
+ throw new IllegalStateException(e);
+ }
+ return true;
+ }
+
+ private Blessings loadBlessings() {
+ try {
+ // See if there are blessings stored in shared preferences.
+ return BlessingsManager.getBlessings(mContext);
+ } catch (VException e) {
+ Log.w(TAG, "Cannot get blessings from prefs: " + e.getMessage());
+ }
+ return null;
+ }
+
+ private void configurePrincipal(Blessings blessings) {
+ try {
+ VPrincipal p = V.getPrincipal(mVContext);
+ p.blessingStore().setDefaultBlessings(blessings);
+ p.blessingStore().set(blessings, new BlessingPattern("..."));
+ VSecurity.addToRoots(p, blessings);
+ mBlessings = blessings;
+ } catch (VException e) {
+ Log.e(TAG, String.format(
+ "Couldn't set local blessing %s: %s", blessings, e.getMessage()));
+ }
+ }
+
+
+}
diff --git a/android/app/src/main/res/layout/activity_sign_in.xml b/android/app/src/main/res/layout/activity_sign_in.xml
new file mode 100644
index 0000000..b3a7afd
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_sign_in.xml
@@ -0,0 +1,11 @@
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:paddingBottom="@dimen/activity_vertical_margin"
+ android:paddingLeft="@dimen/activity_horizontal_margin"
+ android:paddingRight="@dimen/activity_horizontal_margin"
+ android:paddingTop="@dimen/activity_vertical_margin"
+ tools:context="io.v.syncslides.SignInActivity">
+
+</RelativeLayout>
diff --git a/android/app/src/main/res/menu/menu_sign_in.xml b/android/app/src/main/res/menu/menu_sign_in.xml
new file mode 100644
index 0000000..6543eac
--- /dev/null
+++ b/android/app/src/main/res/menu/menu_sign_in.xml
@@ -0,0 +1,10 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ tools:context="io.v.syncslides.SignInActivity">
+ <item
+ android:id="@+id/action_settings"
+ android:orderInCategory="100"
+ android:title="@string/action_settings"
+ app:showAsAction="never"/>
+</menu>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8ac7d35..619268c 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,4 +1,4 @@
<resources>
- <string name="app_name">SyncSlides</string>
+ <string name="app_name">DevSyncSlides</string>
<string name="action_settings">Settings</string>
</resources>