
39 changed files with 1239 additions and 533 deletions
@ -0,0 +1,173 @@
|
||||
package sh.ftp.rocketninelabs.meditationassistant; |
||||
|
||||
|
||||
import android.content.Context; |
||||
import android.content.Intent; |
||||
import android.content.SharedPreferences; |
||||
import android.content.res.Resources; |
||||
import android.net.Uri; |
||||
import android.support.annotation.NonNull; |
||||
import android.support.annotation.Nullable; |
||||
|
||||
import net.openid.appauth.connectivity.ConnectionBuilder; |
||||
import net.openid.appauth.connectivity.DefaultConnectionBuilder; |
||||
|
||||
import org.json.JSONObject; |
||||
|
||||
import java.lang.ref.WeakReference; |
||||
|
||||
/** |
||||
* Reads and validates the demo app configuration from `res/raw/auth_config.json`. Configuration |
||||
* changes are detected by comparing the hash of the last known configuration to the read |
||||
* configuration. When a configuration change is detected, the app state is reset. |
||||
*/ |
||||
public final class AuthConfiguration { |
||||
|
||||
private static final String TAG = "Configuration"; |
||||
|
||||
private static final String PREFS_NAME = "config"; |
||||
private static final String KEY_LAST_HASH = "lastHash"; |
||||
|
||||
private static WeakReference<AuthConfiguration> sInstance = new WeakReference<>(null); |
||||
|
||||
private final Context mContext; |
||||
private final SharedPreferences mPrefs; |
||||
private final Resources mResources; |
||||
|
||||
private JSONObject mConfigJson; |
||||
private String mConfigError; |
||||
|
||||
private String mClientId; |
||||
private String mScope; |
||||
private Uri mRedirectUri; |
||||
private Uri mDiscoveryUri; |
||||
private Uri mAuthEndpointUri; |
||||
private Uri mTokenEndpointUri; |
||||
private Uri mRegistrationEndpointUri; |
||||
|
||||
public static AuthConfiguration getInstance(Context context) { |
||||
AuthConfiguration config = sInstance.get(); |
||||
if (config == null) { |
||||
config = new AuthConfiguration(context); |
||||
sInstance = new WeakReference<AuthConfiguration>(config); |
||||
} |
||||
|
||||
return config; |
||||
} |
||||
|
||||
public AuthConfiguration(Context context) { |
||||
mContext = context; |
||||
mPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); |
||||
mResources = context.getResources(); |
||||
|
||||
try { |
||||
readConfiguration(); |
||||
} catch (InvalidConfigurationException ex) { |
||||
mConfigError = ex.getMessage(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Indicates whether the current configuration is valid. |
||||
*/ |
||||
public boolean isValid() { |
||||
return mConfigError == null; |
||||
} |
||||
|
||||
/** |
||||
* Returns a description of the configuration error, if the configuration is invalid. |
||||
*/ |
||||
@Nullable |
||||
public String getConfigurationError() { |
||||
return mConfigError; |
||||
} |
||||
|
||||
|
||||
@Nullable |
||||
public String getClientId() { |
||||
return mClientId; |
||||
} |
||||
|
||||
@NonNull |
||||
public String getScope() { |
||||
return mScope; |
||||
} |
||||
|
||||
@NonNull |
||||
public Uri getRedirectUri() { |
||||
return mRedirectUri; |
||||
} |
||||
|
||||
@Nullable |
||||
public Uri getDiscoveryUri() { |
||||
return mDiscoveryUri; |
||||
} |
||||
|
||||
@Nullable |
||||
public Uri getAuthEndpointUri() { |
||||
return mAuthEndpointUri; |
||||
} |
||||
|
||||
@Nullable |
||||
public Uri getTokenEndpointUri() { |
||||
return mTokenEndpointUri; |
||||
} |
||||
|
||||
@Nullable |
||||
public Uri getRegistrationEndpointUri() { |
||||
return mRegistrationEndpointUri; |
||||
} |
||||
|
||||
public ConnectionBuilder getConnectionBuilder() { |
||||
return DefaultConnectionBuilder.INSTANCE; |
||||
} |
||||
|
||||
private void readConfiguration() throws InvalidConfigurationException { |
||||
mClientId = ""; // TODO: blank?
|
||||
mScope = "openid email profile"; |
||||
mRedirectUri = Uri.parse("https://medinet.rocketnine.space/oauth"); |
||||
|
||||
if (!isRedirectUriRegistered()) { |
||||
throw new InvalidConfigurationException( |
||||
"redirect_uri is not handled by any activity in this app! " |
||||
+ "Ensure that the appAuthRedirectScheme in your build.gradle file " |
||||
+ "is correctly configured, or that an appropriate intent filter " |
||||
+ "exists in your app manifest."); |
||||
} |
||||
|
||||
/*if (getConfigString("discovery_uri") == null) { |
||||
mAuthEndpointUri = getRequiredConfigWebUri("authorization_endpoint_uri"); |
||||
|
||||
mTokenEndpointUri = getRequiredConfigWebUri("token_endpoint_uri"); |
||||
|
||||
if (mClientId == null) { |
||||
mRegistrationEndpointUri = getRequiredConfigWebUri("registration_endpoint_uri"); |
||||
} |
||||
} else { |
||||
mDiscoveryUri = getRequiredConfigWebUri("discovery_uri"); |
||||
}*/ |
||||
mDiscoveryUri = Uri.parse(""); |
||||
} |
||||
|
||||
private boolean isRedirectUriRegistered() { |
||||
// ensure that the redirect URI declared in the configuration is handled by some activity
|
||||
// in the app, by querying the package manager speculatively
|
||||
Intent redirectIntent = new Intent(); |
||||
redirectIntent.setPackage(mContext.getPackageName()); |
||||
redirectIntent.setAction(Intent.ACTION_VIEW); |
||||
redirectIntent.addCategory(Intent.CATEGORY_BROWSABLE); |
||||
redirectIntent.setData(mRedirectUri); |
||||
|
||||
return !mContext.getPackageManager().queryIntentActivities(redirectIntent, 0).isEmpty(); |
||||
} |
||||
|
||||
public static final class InvalidConfigurationException extends Exception { |
||||
InvalidConfigurationException(String reason) { |
||||
super(reason); |
||||
} |
||||
|
||||
InvalidConfigurationException(String reason, Throwable cause) { |
||||
super(reason, cause); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,225 @@
|
||||
package sh.ftp.rocketninelabs.meditationassistant; |
||||
|
||||
|
||||
import android.app.Activity; |
||||
import android.os.Bundle; |
||||
import android.support.annotation.MainThread; |
||||
import android.support.annotation.Nullable; |
||||
import android.support.annotation.WorkerThread; |
||||
import android.support.design.widget.Snackbar; |
||||
import android.util.Log; |
||||
import android.view.View; |
||||
import android.widget.TextView; |
||||
|
||||
import net.openid.appauth.AppAuthConfiguration; |
||||
import net.openid.appauth.AuthState; |
||||
import net.openid.appauth.AuthorizationException; |
||||
import net.openid.appauth.AuthorizationResponse; |
||||
import net.openid.appauth.AuthorizationService; |
||||
import net.openid.appauth.AuthorizationServiceDiscovery; |
||||
import net.openid.appauth.ClientAuthentication; |
||||
import net.openid.appauth.TokenRequest; |
||||
import net.openid.appauth.TokenResponse; |
||||
|
||||
import org.json.JSONException; |
||||
import org.json.JSONObject; |
||||
|
||||
import java.io.IOException; |
||||
import java.net.HttpURLConnection; |
||||
import java.net.MalformedURLException; |
||||
import java.net.URL; |
||||
import java.nio.charset.Charset; |
||||
import java.util.concurrent.ExecutorService; |
||||
import java.util.concurrent.Executors; |
||||
import java.util.concurrent.atomic.AtomicReference; |
||||
|
||||
import okio.Okio; |
||||
|
||||
/** |
||||
* Displays the authorized state of the user. This activity is provided with the outcome of the |
||||
* authorization flow, which it uses to negotiate the final authorized state, |
||||
* by performing an authorization code exchange if necessary. After this, the activity provides |
||||
* additional post-authorization operations if available, such as fetching user info and refreshing |
||||
* access tokens. |
||||
*/ |
||||
public class AuthResultActivity extends Activity { |
||||
MeditationAssistant ma = null; |
||||
|
||||
private static final String KEY_USER_INFO = "userInfo"; |
||||
|
||||
private AuthorizationService mAuthService; |
||||
private AuthStateManager mStateManager; |
||||
private final AtomicReference<JSONObject> mUserInfoJson = new AtomicReference<>(); |
||||
private ExecutorService mExecutor; |
||||
|
||||
@Override |
||||
protected void onCreate(Bundle savedInstanceState) { |
||||
super.onCreate(savedInstanceState); |
||||
|
||||
mStateManager = AuthStateManager.getInstance(this); |
||||
mExecutor = Executors.newSingleThreadExecutor(); |
||||
|
||||
AuthConfiguration config = AuthConfiguration.getInstance(this); |
||||
|
||||
mAuthService = new AuthorizationService( |
||||
this, |
||||
new AppAuthConfiguration.Builder() |
||||
.setConnectionBuilder(config.getConnectionBuilder()) |
||||
.build()); |
||||
|
||||
displayLoading("Restoring state..."); |
||||
|
||||
if (savedInstanceState != null) { |
||||
try { |
||||
mUserInfoJson.set(new JSONObject(savedInstanceState.getString(KEY_USER_INFO))); |
||||
} catch (JSONException ex) { |
||||
Log.e("MA", "Failed to parse saved user info JSON, discarding", ex); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
protected void onStart() { |
||||
super.onStart(); |
||||
|
||||
if (mExecutor.isShutdown()) { |
||||
mExecutor = Executors.newSingleThreadExecutor(); |
||||
} |
||||
|
||||
if (mStateManager.getCurrent().isAuthorized()) { |
||||
updateState(); |
||||
return; |
||||
} |
||||
|
||||
// the stored AuthState is incomplete, so check if we are currently receiving the result of
|
||||
// the authorization flow from the browser.
|
||||
AuthorizationResponse response = AuthorizationResponse.fromIntent(getIntent()); |
||||
AuthorizationException ex = AuthorizationException.fromIntent(getIntent()); |
||||
|
||||
if (response != null || ex != null) { |
||||
mStateManager.updateAfterAuthorization(response, ex); |
||||
} |
||||
|
||||
if (response != null && response.authorizationCode != null) { |
||||
// authorization code exchange is required
|
||||
mStateManager.updateAfterAuthorization(response, ex); |
||||
exchangeAuthorizationCode(response); |
||||
} else if (ex != null) { |
||||
// TODO: handle failure "Authorization flow failed: " + ex.getMessage());
|
||||
} else { |
||||
// TODO: handle failure No authorization state retained - reauthorization required");
|
||||
} |
||||
} |
||||
|
||||
@Override |
||||
protected void onSaveInstanceState(Bundle state) { |
||||
// user info is retained to survive activity restarts, such as when rotating the
|
||||
// device or switching apps. This isn't essential, but it helps provide a less
|
||||
// jarring UX when these events occur - data does not just disappear from the view.
|
||||
if (mUserInfoJson.get() != null) { |
||||
state.putString(KEY_USER_INFO, mUserInfoJson.toString()); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
protected void onDestroy() { |
||||
super.onDestroy(); |
||||
mAuthService.dispose(); |
||||
mExecutor.shutdownNow(); |
||||
} |
||||
|
||||
@MainThread |
||||
private void displayLoading(String message) { |
||||
Log.d("MA", "Auth: " + message); |
||||
} |
||||
|
||||
@MainThread |
||||
private void updateState() { |
||||
AuthState state = mStateManager.getCurrent(); |
||||
|
||||
if (state.getAccessToken() == null) { |
||||
Log.d("MA", "Access token was null: "); |
||||
// TODO: Handle auth failure, prompt to retry
|
||||
finish(); |
||||
return; |
||||
} |
||||
|
||||
Long expiresAt = state.getAccessTokenExpirationTime(); |
||||
if (expiresAt != null && expiresAt < System.currentTimeMillis()) { |
||||
refreshAccessToken(); |
||||
return; |
||||
} |
||||
|
||||
Log.d("MA", "Got token"); |
||||
getMeditationAssistant().getMediNET().signInWithAuthToken(state.getAccessToken()); |
||||
finish(); |
||||
} |
||||
|
||||
@MainThread |
||||
private void refreshAccessToken() { |
||||
displayLoading("Refreshing access token"); |
||||
performTokenRequest( |
||||
mStateManager.getCurrent().createTokenRefreshRequest(), |
||||
this::handleAccessTokenResponse); |
||||
} |
||||
|
||||
@MainThread |
||||
private void exchangeAuthorizationCode(AuthorizationResponse authorizationResponse) { |
||||
displayLoading("Exchanging authorization code"); |
||||
performTokenRequest( |
||||
authorizationResponse.createTokenExchangeRequest(), |
||||
this::handleCodeExchangeResponse); |
||||
} |
||||
|
||||
@MainThread |
||||
private void performTokenRequest( |
||||
TokenRequest request, |
||||
AuthorizationService.TokenResponseCallback callback) { |
||||
ClientAuthentication clientAuthentication; |
||||
try { |
||||
clientAuthentication = mStateManager.getCurrent().getClientAuthentication(); |
||||
} catch (ClientAuthentication.UnsupportedAuthenticationMethod ex) { |
||||
Log.d("MA", "Token request cannot be made, client authentication for the token " |
||||
+ "endpoint could not be constructed (%s)", ex); |
||||
// TODO handle failure Client authentication method is unsupported");
|
||||
return; |
||||
} |
||||
|
||||
mAuthService.performTokenRequest( |
||||
request, |
||||
clientAuthentication, |
||||
callback); |
||||
} |
||||
|
||||
@WorkerThread |
||||
private void handleAccessTokenResponse( |
||||
@Nullable TokenResponse tokenResponse, |
||||
@Nullable AuthorizationException authException) { |
||||
mStateManager.updateAfterTokenResponse(tokenResponse, authException); |
||||
runOnUiThread(this::updateState); |
||||
} |
||||
|
||||
@WorkerThread |
||||
private void handleCodeExchangeResponse( |
||||
@Nullable TokenResponse tokenResponse, |
||||
@Nullable AuthorizationException authException) { |
||||
|
||||
mStateManager.updateAfterTokenResponse(tokenResponse, authException); |
||||
if (!mStateManager.getCurrent().isAuthorized()) { |
||||
final String message = "Authorization Code exchange failed" |
||||
+ ((authException != null) ? authException.error : ""); |
||||
|
||||
// WrongThread inference is incorrect for lambdas
|
||||
//noinspection WrongThread
|
||||
// TODO: Handle not authorized, show error
|
||||
} else { |
||||
runOnUiThread(this::updateState); |
||||
} |
||||
} |
||||
public MeditationAssistant getMeditationAssistant() { |
||||
if (ma == null) { |
||||
ma = (MeditationAssistant) this.getApplication(); |
||||
} |
||||
return ma; |
||||
} |
||||
} |
@ -0,0 +1,154 @@
|
||||
package sh.ftp.rocketninelabs.meditationassistant; |
||||
|
||||
import android.content.Context; |
||||
import android.content.SharedPreferences; |
||||
import android.support.annotation.AnyThread; |
||||
import android.support.annotation.NonNull; |
||||
import android.support.annotation.Nullable; |
||||
import android.util.Log; |
||||
|
||||
import net.openid.appauth.AuthState; |
||||
import net.openid.appauth.AuthorizationException; |
||||
import net.openid.appauth.AuthorizationResponse; |
||||
import net.openid.appauth.RegistrationResponse; |
||||
import net.openid.appauth.TokenResponse; |
||||
|
||||
import org.json.JSONException; |
||||
|
||||
import java.lang.ref.WeakReference; |
||||
import java.util.concurrent.atomic.AtomicReference; |
||||
import java.util.concurrent.locks.ReentrantLock; |
||||
|
||||
/** |
||||
* An example persistence mechanism for an {@link AuthState} instance. |
||||
* This stores the instance in a shared preferences file, and provides thread-safe access and |
||||
* mutation. |
||||
*/ |
||||
public class AuthStateManager { |
||||
|
||||
private static final AtomicReference<WeakReference<AuthStateManager>> INSTANCE_REF = |
||||
new AtomicReference<>(new WeakReference<AuthStateManager>(null)); |
||||
|
||||
private static final String TAG = "AuthStateManager"; |
||||
|
||||
private static final String STORE_NAME = "AuthState"; |
||||
private static final String KEY_STATE = "state"; |
||||
|
||||
private final SharedPreferences mPrefs; |
||||
private final ReentrantLock mPrefsLock; |
||||
private final AtomicReference<AuthState> mCurrentAuthState; |
||||
|
||||
@AnyThread |
||||
public static AuthStateManager getInstance(@NonNull Context context) { |
||||
AuthStateManager manager = INSTANCE_REF.get().get(); |
||||
if (manager == null) { |
||||
manager = new AuthStateManager(context.getApplicationContext()); |
||||
INSTANCE_REF.set(new WeakReference<>(manager)); |
||||
} |
||||
|
||||
return manager; |
||||
} |
||||
|
||||
private AuthStateManager(Context context) { |
||||
mPrefs = context.getSharedPreferences(STORE_NAME, Context.MODE_PRIVATE); |
||||
mPrefsLock = new ReentrantLock(); |
||||
mCurrentAuthState = new AtomicReference<>(); |
||||
} |
||||
|
||||
@AnyThread |
||||
@NonNull |
||||
public AuthState getCurrent() { |
||||
if (mCurrentAuthState.get() != null) { |
||||
return mCurrentAuthState.get(); |
||||
} |
||||
|
||||
AuthState state = readState(); |
||||
if (mCurrentAuthState.compareAndSet(null, state)) { |
||||
return state; |
||||
} else { |
||||
return mCurrentAuthState.get(); |
||||
} |
||||
} |
||||
|
||||
@AnyThread |
||||
@NonNull |
||||
public AuthState replace(@NonNull AuthState state) { |
||||
writeState(state); |
||||
mCurrentAuthState.set(state); |
||||
return state; |
||||
} |
||||
|
||||
@AnyThread |
||||
@NonNull |
||||
public AuthState updateAfterAuthorization( |
||||
@Nullable AuthorizationResponse response, |
||||
@Nullable AuthorizationException ex) { |
||||
AuthState current = getCurrent(); |
||||
current.update(response, ex); |
||||
return replace(current); |
||||
} |
||||
|
||||
@AnyThread |
||||
@NonNull |
||||
public AuthState updateAfterTokenResponse( |
||||
@Nullable TokenResponse response, |
||||
@Nullable AuthorizationException ex) { |
||||
AuthState current = getCurrent(); |
||||
current.update(response, ex); |
||||
return replace(current); |
||||
} |
||||
|
||||
@AnyThread |
||||
@NonNull |
||||
public AuthState updateAfterRegistration( |
||||
RegistrationResponse response, |
||||
AuthorizationException ex) { |
||||
AuthState current = getCurrent(); |
||||
if (ex != null) { |
||||
return current; |
||||
} |
||||
|
||||
current.update(response); |
||||
return replace(current); |
||||
} |
||||
|
||||
@AnyThread |
||||
@NonNull |
||||
private AuthState readState() { |
||||
mPrefsLock.lock(); |
||||
try { |
||||
String currentState = mPrefs.getString(KEY_STATE, null); |
||||
if (currentState == null) { |
||||
return new AuthState(); |
||||
} |
||||
|
||||
try { |
||||
return AuthState.jsonDeserialize(currentState); |
||||
} catch (JSONException ex) { |
||||
Log.w(TAG, "Failed to deserialize stored auth state - discarding"); |
||||
return new AuthState(); |
||||
} |
||||
} finally { |
||||
mPrefsLock.unlock(); |
||||
} |
||||
} |
||||
|
||||
@AnyThread |
||||
private void writeState(@Nullable AuthState state) { |
||||
mPrefsLock.lock(); |
||||
try { |
||||
SharedPreferences.Editor editor = mPrefs.edit(); |
||||
if (state == null) { |
||||
editor.remove(KEY_STATE); |
||||
} else { |
||||
editor.putString(KEY_STATE, state.jsonSerializeString()); |
||||
} |
||||
|
||||
if (!editor.commit()) { |
||||
throw new IllegalStateException("Failed to write state to shared prefs"); |
||||
} |
||||
} finally { |
||||
mPrefsLock.unlock(); |
||||
} |
||||
} |
||||
} |