From b5396bab6fc40137a762e1384840be3336996a72 Mon Sep 17 00:00:00 2001 From: Ian Fijolek Date: Thu, 19 Dec 2019 17:21:38 -0800 Subject: [PATCH] Working code with everything squashed --- .drone.yml | 41 ++ .gitignore | 8 + .idea/compiler.xml | 22 + .idea/copyright/profiles_settings.xml | 3 + .idea/dictionaries/iamthefij.xml | 8 + .idea/encodings.xml | 6 + .idea/gradle.xml | 18 + .idea/misc.xml | 65 ++ .idea/modules.xml | 9 + .idea/runConfigurations.xml | 12 + .idea/vcs.xml | 6 + LICENSE | 2 +- README.md | 11 + build.gradle | 24 + com.iamthefij.otbeta/.gitignore | 1 + com.iamthefij.otbeta/build.gradle | 51 ++ com.iamthefij.otbeta/proguard-rules.pro | 17 + .../com/iamthefij/otbeta/ApplicationTest.java | 13 + .../src/main/AndroidManifest.xml | 48 ++ .../src/main/ic_launcher-web.png | Bin 0 -> 33248 bytes .../com/iamthefij/otbeta/ExerciserStore.java | 50 ++ .../com/iamthefij/otbeta/LoginActivity.java | 609 ++++++++++++++++++ .../otbeta/PersistentCookieStore.java | 148 +++++ .../com/iamthefij/otbeta/SplashActivity.java | 31 + .../otbeta/WorkoutDetailActivity.java | 73 +++ .../otbeta/WorkoutDetailFragment.java | 230 +++++++ .../iamthefij/otbeta/WorkoutListActivity.java | 262 ++++++++ .../java/com/iamthefij/otbeta/api/Client.java | 204 ++++++ .../com/iamthefij/otbeta/api/Exerciser.java | 139 ++++ .../otbeta/api/ExerciserProfile.java | 119 ++++ .../com/iamthefij/otbeta/api/Interval.java | 156 +++++ .../com/iamthefij/otbeta/api/Location.java | 188 ++++++ .../com/iamthefij/otbeta/api/Workout.java | 234 +++++++ .../main/res/layout-w900dp/workout_list.xml | 37 ++ .../src/main/res/layout/activity_login.xml | 86 +++ .../src/main/res/layout/activity_splash.xml | 8 + .../res/layout/activity_workout_detail.xml | 43 ++ .../main/res/layout/activity_workout_list.xml | 33 + .../src/main/res/layout/workout_card_view.xml | 64 ++ .../src/main/res/layout/workout_detail.xml | 133 ++++ .../src/main/res/layout/workout_list.xml | 11 + .../main/res/layout/workout_list_content.xml | 20 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3235 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2027 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4687 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7366 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10664 bytes .../src/main/res/values/colors.xml | 6 + .../src/main/res/values/dimens.xml | 10 + .../src/main/res/values/strings.xml | 21 + .../src/main/res/values/styles.xml | 20 + .../src/main/res/xml/backup_descriptor.xml | 4 + .../otbeta/LocationSortUnitTest.java | 92 +++ gradle.properties | 18 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 160 +++++ gradlew.bat | 90 +++ settings.gradle | 1 + 59 files changed, 3670 insertions(+), 1 deletion(-) create mode 100644 .drone.yml create mode 100644 .idea/compiler.xml create mode 100644 .idea/copyright/profiles_settings.xml create mode 100644 .idea/dictionaries/iamthefij.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/vcs.xml create mode 100644 build.gradle create mode 100644 com.iamthefij.otbeta/.gitignore create mode 100644 com.iamthefij.otbeta/build.gradle create mode 100644 com.iamthefij.otbeta/proguard-rules.pro create mode 100644 com.iamthefij.otbeta/src/androidTest/java/com/iamthefij/otbeta/ApplicationTest.java create mode 100644 com.iamthefij.otbeta/src/main/AndroidManifest.xml create mode 100644 com.iamthefij.otbeta/src/main/ic_launcher-web.png create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/ExerciserStore.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/LoginActivity.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/PersistentCookieStore.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/SplashActivity.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutDetailActivity.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutDetailFragment.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutListActivity.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Client.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Exerciser.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/ExerciserProfile.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Interval.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Location.java create mode 100644 com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Workout.java create mode 100644 com.iamthefij.otbeta/src/main/res/layout-w900dp/workout_list.xml create mode 100644 com.iamthefij.otbeta/src/main/res/layout/activity_login.xml create mode 100644 com.iamthefij.otbeta/src/main/res/layout/activity_splash.xml create mode 100644 com.iamthefij.otbeta/src/main/res/layout/activity_workout_detail.xml create mode 100644 com.iamthefij.otbeta/src/main/res/layout/activity_workout_list.xml create mode 100644 com.iamthefij.otbeta/src/main/res/layout/workout_card_view.xml create mode 100644 com.iamthefij.otbeta/src/main/res/layout/workout_detail.xml create mode 100644 com.iamthefij.otbeta/src/main/res/layout/workout_list.xml create mode 100644 com.iamthefij.otbeta/src/main/res/layout/workout_list_content.xml create mode 100644 com.iamthefij.otbeta/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 com.iamthefij.otbeta/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 com.iamthefij.otbeta/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 com.iamthefij.otbeta/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 com.iamthefij.otbeta/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 com.iamthefij.otbeta/src/main/res/values/colors.xml create mode 100644 com.iamthefij.otbeta/src/main/res/values/dimens.xml create mode 100644 com.iamthefij.otbeta/src/main/res/values/strings.xml create mode 100644 com.iamthefij.otbeta/src/main/res/values/styles.xml create mode 100644 com.iamthefij.otbeta/src/main/res/xml/backup_descriptor.xml create mode 100644 com.iamthefij.otbeta/src/test/java/com/iamthefij/otbeta/LocationSortUnitTest.java create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..b372b98 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,41 @@ +workspace: + base: /src + path: . + +pipeline: + build: + image: openjdk:8-jdk + environment: + - ANDROID_COMPILE_SDK=25 + - ANDROID_BUILD_TOOLS=25.0.2 + - ANDROID_SDK_TOOLS=3859397 + commands: + - chmod +x ./gradlew + - export ANDROID_HOME=/src/android-sdk-linux + - export PATH=$${PATH}:$${ANDROID_HOME}/platform-tools/ + - export GRADLE_USER_HOME=/src/.gradle + - apt-get --quiet update --yes + - apt-get --quiet install --yes wget tar unzip lib32stdc++6 lib32z1 + - wget --no-clobber --quiet --output-document=sdk-tools-linux-$${ANDROID_SDK_TOOLS}.zip https://dl.google.com/android/repository/sdk-tools-linux-$${ANDROID_SDK_TOOLS}.zip || true + - unzip -d $${ANDROID_HOME}/ sdk-tools-linux-$${ANDROID_SDK_TOOLS}.zip + - mkdir -p $${ANDROID_HOME}/licenses/ + - echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > $${ANDROID_HOME}/licenses/android-sdk-license + - mkdir -p $${HOME}/.android + - touch $${HOME}/.android/repositories.cfg + - echo y | $${ANDROID_HOME}/tools/bin/sdkmanager --update + - echo y | $${ANDROID_HOME}/tools/bin/sdkmanager "platforms;android-$${ANDROID_COMPILE_SDK}" + - echo y | $${ANDROID_HOME}/tools/bin/sdkmanager "build-tools;$${ANDROID_BUILD_TOOLS}" + - echo y | $${ANDROID_HOME}/tools/bin/sdkmanager "extras;android;m2repository" + - echo y | $${ANDROID_HOME}/tools/bin/sdkmanager "extras;google;m2repository" + - echo y | $${ANDROID_HOME}/tools/bin/sdkmanager "extras;google;google_play_services" + - ./gradlew build + + upload_debug: + image: vividboarder/drone-webdav + file: com.iamthefij.otbeta/build/outputs/apk/com.iamthefij.otbeta-debug.apk + destination: https://cloud.iamthefij.com/remote.php/dav/files/iamthefij/Android/Apks/ + secrets: + - source: WEBDAV_USER + target: PLUGIN_USERNAME + - source: WEBDAV_PASSWORD + target: PLUGIN_PASSWORD diff --git a/.gitignore b/.gitignore index 0ce594f..bc42322 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,11 @@ proguard/ # Android Studio captures folder captures/ +# Android Studio project files +*.iml +/.idea/workspace.xml +/.idea/libraries + +# OSX things +.DS_Store +.tags diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..9a8b7e5 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000..e7bedf3 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.idea/dictionaries/iamthefij.xml b/.idea/dictionaries/iamthefij.xml new file mode 100644 index 0000000..ab55d60 --- /dev/null +++ b/.idea/dictionaries/iamthefij.xml @@ -0,0 +1,8 @@ + + + + otbeta + iamthefij + + + diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..97626ba --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..10e824a --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..c958f32 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.6 + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..b556fc8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE index 472ac23..8845408 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ MIT License -Copyright (c) +Copyright (c) 2017 Ian Fijolek 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: diff --git a/README.md b/README.md index ed258ff..ade4eea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,13 @@ # otbeta +Simple Open Source implementation of the OTBeat app for Android. + +### Note +This is an Unofficial app. The API may change without notice and this client may break. + +### TODO + - [x] Clean all personal info (names) + - [x] Add proper docstrings + - [ ] Squash down history + - [ ] Infinite scrolling to load more summaries + - [ ] Fix request caching or use a local state to persist data diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..86d1d63 --- /dev/null +++ b/build.gradle @@ -0,0 +1,24 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:2.3.0' + classpath 'de.mobilej.unmock:UnMockPlugin:0.6.0' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/com.iamthefij.otbeta/.gitignore b/com.iamthefij.otbeta/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/com.iamthefij.otbeta/.gitignore @@ -0,0 +1 @@ +/build diff --git a/com.iamthefij.otbeta/build.gradle b/com.iamthefij.otbeta/build.gradle new file mode 100644 index 0000000..96e62e3 --- /dev/null +++ b/com.iamthefij.otbeta/build.gradle @@ -0,0 +1,51 @@ +apply plugin: 'com.android.application' +apply plugin: 'de.mobilej.unmock' + +android { + compileSdkVersion 25 + buildToolsVersion '25.0.2' + + defaultConfig { + applicationId "com.iamthefij.otbeta" + minSdkVersion 22 + targetSdkVersion 25 + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + dexOptions { + javaMaxHeapSize "3g" + } +} + +unMock { + keep 'android.location.Location' + keep 'android.util.SparseArray' + keep 'com.android.internal.util.ArrayUtils' + keep 'com.android.internal.util.GrowingArrayUtils' + keep 'org.json.JSON' + keep 'org.json.JSONObject' +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + // https://mvnrepository.com/artifact/com.goebl/david-webb + compile group: 'com.goebl', name: 'david-webb', version: '1.3.0' + // https://mvnrepository.com/artifact/com.google.code.gson/gson + compile group: 'com.google.code.gson', name: 'gson', version: '2.8.0' + // http://www.android-graphview.org/download-getting-started/ + compile 'com.jjoe64:graphview:4.2.1' + + compile 'com.android.support:appcompat-v7:25.2.0' + compile 'com.android.support:design:25.2.0' + compile 'com.android.support.constraint:constraint-layout:1.0.2' + compile 'com.android.support:cardview-v7:25.2.0' + compile 'com.android.support:support-v4:25.2.0' + compile 'com.android.support:recyclerview-v7:25.2.0' + testCompile 'junit:junit:4.12' +} diff --git a/com.iamthefij.otbeta/proguard-rules.pro b/com.iamthefij.otbeta/proguard-rules.pro new file mode 100644 index 0000000..aee8b13 --- /dev/null +++ b/com.iamthefij.otbeta/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in $HOME/workspace/android-sdk-linux/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/com.iamthefij.otbeta/src/androidTest/java/com/iamthefij/otbeta/ApplicationTest.java b/com.iamthefij.otbeta/src/androidTest/java/com/iamthefij/otbeta/ApplicationTest.java new file mode 100644 index 0000000..8aa091a --- /dev/null +++ b/com.iamthefij.otbeta/src/androidTest/java/com/iamthefij/otbeta/ApplicationTest.java @@ -0,0 +1,13 @@ +package com.iamthefij.otbeta; + +import android.app.Application; +import android.test.ApplicationTestCase; + +/** + * Testing Fundamentals + */ +public class ApplicationTest extends ApplicationTestCase { + public ApplicationTest() { + super(Application.class); + } +} diff --git a/com.iamthefij.otbeta/src/main/AndroidManifest.xml b/com.iamthefij.otbeta/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3666489 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/com.iamthefij.otbeta/src/main/ic_launcher-web.png b/com.iamthefij.otbeta/src/main/ic_launcher-web.png new file mode 100644 index 0000000000000000000000000000000000000000..04323657c641784ab122b3d70cf09f9e21fb5a89 GIT binary patch literal 33248 zcmd2?_dnI|`@hd|?7d~nUfChz9F?TZC}c*F5Ye#DAt}iy%3dWzC1jL!$Q~ie$liNp zJI?vs-rs-X`%^t0?$>=^`+8pEM3@-q(bI6#008LqFP^^w022Ne38*OGKU=V@Q!00E4_`QIeO;&LZ< zv)InaB=n*Cx7acKS?8R2?#$-{|=`_PbJKe*bi}!&)XS$@(_R&Kn5sQA(wK9(ft9_xf-f zhja4kYd7DcvCxX`D4_Yhp)vbxEQz!_sWM*p#Mkg^e~xPPwL78PuKp|QC?QDn*)j2h zhH;*SpEeOOgfQ_UyHs`WD$yj;hWuQ-aF2_IQ9E6QE_30_o$G18>@+sGe`fW^(>^v{ zP|4}%Hs(F1CtrI|Pvv8lSRXbt!!AW~szM7qTRh{{mV}wej}0zwor!PzdU>GB9p5SU@#P01**3=F7bT5;%py`r9*mIl zp^Vw(iH`|W!mNz}Y%co()h@o?%Z$1@tKShL&VuE!du{ezzMtdYPC$*?Ix0sNt%RXtJ!g&9pTEH5eftt(Km5?H`)o`+li#Ye2bL z=I(mzlvb2aRkLSrK(x33WG+y~((&>2GPQOkO-Vw-i8|v5THh-JbMq#8Z&l0WV;cfG z$9(+P7EWZkgrrjkDjr+Gvs?mK+-vMYZL4duafoSj0Kq^cAcK=UX{ZFBLG6EfPw8wI zjkW@0VB%9QVWx|4Ia+nhkp6~u=zB8KtBfXf+)YMP6FSO^15FVa=~XXgihh%+6lP;o*0+F`D24_kgyl0d@b zr_PzZmL!G*Es-D-c1OXKbanro`4ibL%+@SW@NX4avFXHWlY3rSD3 z6yTxw(+eecNX>6$%P%Rs6cer6e8M7-6qe0crBAnxeMqiVxkcAoV$h|mJ$S5i%SnOX zd1oT2Kee(F&q2q{Wb@RDfrqW=x|IyN6t#M{+#!Nq1%jeO=qbPP)BAu zqNYzpJ-;pcz6`_JNu&%wRw07*8P!!T5Sk8h_j| z7S-P5AvTtE-WZCD>D9(u&9oH4WV~LvR8@L{mx7rpEJ2h=(LdApO1JPq0k1$(h$0%? z`o_;^w^|p(x#)^q5tcDe9FdgGvb<4-fW$&d3Xuy}+A-9r3nINy5 zVVKUmWOefEDSEj^#exR>3#@9FGIS1PCV8l#s{PklNy3-T-oz4TN@yblfGt&sEz}PP zI=w;v1S2S=navgVn}c(|lGnx7;yRgtNHEAhPxUdkOCspnMie~J#wT;SI9m4SW>Km` zT4Sw)`$YnnzfLtUUveqK>R@@2hYs3`_C{B^O$MJ+@HPD}bgw5r&Bg8^*7>2F;E7)@ ziQ4UZ7hon6eZDNQl;(Hu&ox5iP4p~0viKZNtg>`lTE5+GyXOp#=)O7hnUX2|Cl~bu zzQc8A41e~(-6f{HuThl`8M3Fb5p%~}OQS7NmGo+UX22la8x5KYD5iT{G{uMRGX~9W zbUwxG`KG2Yt!8kt?rW1A2c9-C;$m!Q*Sj=>b~fmrVfH3JWF!;b%0AkVdrU2{OE&)a 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 literal 0 HcmV?d00001 diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/ExerciserStore.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/ExerciserStore.java new file mode 100644 index 0000000..65e819d --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/ExerciserStore.java @@ -0,0 +1,50 @@ +package com.iamthefij.otbeta; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.google.gson.Gson; +import com.iamthefij.otbeta.api.Exerciser; + +/** + * Store authenticated user information to device shared preferences + */ + +class ExerciserStore { + + private static final String PREF_DEFAULT_STRING = ""; + private static final String PREF_EXERCISER_KEY = "exerciser_json"; + private static final String PREFS_NAME = ExerciserStore.class.getName(); + private final Context mContext; + + public ExerciserStore(Context context) { + mContext = context; + } + + public Exerciser getExerciser() { + Exerciser exerciser = null; + String exerciserJson = getExerciserJsonString(); + if (!exerciserJson.equals(PREF_DEFAULT_STRING)) { + Gson gson = new Gson(); + exerciser = gson.fromJson(exerciserJson, Exerciser.class); + } + + return exerciser; + } + + public void saveExerciser(Exerciser exerciser) { + Gson gson = new Gson(); + String jsonSessionCookieString = gson.toJson(exerciser); + SharedPreferences.Editor editor = getPrefs().edit(); + editor.putString(PREF_EXERCISER_KEY, jsonSessionCookieString); + editor.apply(); + } + + private String getExerciserJsonString() { + return getPrefs().getString(PREF_EXERCISER_KEY, PREF_DEFAULT_STRING); + } + + private SharedPreferences getPrefs() { + return mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/LoginActivity.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/LoginActivity.java new file mode 100644 index 0000000..c15e01b --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/LoginActivity.java @@ -0,0 +1,609 @@ +package com.iamthefij.otbeta; + +import android.Manifest; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.annotation.TargetApi; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.location.LocationListener; +import android.location.LocationManager; +import android.support.annotation.NonNull; +import android.support.design.widget.Snackbar; +import android.support.v4.app.ActivityCompat; +import android.support.v7.app.AppCompatActivity; +import android.app.LoaderManager.LoaderCallbacks; + +import android.content.CursorLoader; +import android.content.Loader; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; + +import android.os.Build; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; +import android.view.KeyEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.ArrayAdapter; +import android.widget.AutoCompleteTextView; +import android.widget.BaseAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.SpinnerAdapter; +import android.widget.TextView; + +import com.iamthefij.otbeta.api.Client; +import com.iamthefij.otbeta.api.Exerciser; +import com.iamthefij.otbeta.api.Location; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import static android.Manifest.permission.READ_CONTACTS; +import static android.Manifest.permission.ACCESS_FINE_LOCATION; + +/** + * A login screen that offers login via email/password. + */ +public class LoginActivity extends AppCompatActivity implements LoaderCallbacks { + + /** + * Ids to identity permission requests. + */ + private static final int REQUEST_READ_CONTACTS = 0; + private static final int REQUEST_ACCESS_FINE_LOCATION = 1; + + /** + * Keep track of the login task to ensure we can cancel it if requested. + */ + private UserLoginTask mAuthTask = null; + + // UI references. + private Spinner mLocationView; + private AutoCompleteTextView mEmailView; + private EditText mPasswordView; + private View mProgressView; + private View mLoginFormView; + private Client mClient; + private Exerciser mExerciser; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_login); + + // Initialize client + mClient = new Client(this); + + // Set up the login form. + mLocationView = (Spinner) findViewById(R.id.location); + populateLocationList(); + + mEmailView = (AutoCompleteTextView) findViewById(R.id.email); + populateAutoComplete(); + + mPasswordView = (EditText) findViewById(R.id.password); + mPasswordView.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView textView, int id, KeyEvent keyEvent) { + if (id == R.id.login || id == EditorInfo.IME_NULL) { + attemptLogin(); + return true; + } + return false; + } + }); + + Button mEmailSignInButton = (Button) findViewById(R.id.email_sign_in_button); + mEmailSignInButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + attemptLogin(); + } + }); + + mLoginFormView = findViewById(R.id.login_form); + mProgressView = findViewById(R.id.login_progress); + } + + private void populateAutoComplete() { + if (!mayRequestContacts()) { + return; + } + + getLoaderManager().initLoader(0, null, this); + } + + private void populateLocationList() { + LocationListFetcher locationListFetcher = new LocationListFetcher(); + locationListFetcher.execute(); + } + + private void sortLocationList() { + if (!mayRequestLocation()) { + return; + } + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + return; + } + final LocationSpinnerAdapter locationSpinnerAdapter = (LocationSpinnerAdapter) mLocationView.getAdapter(); + if (locationSpinnerAdapter != null && !locationSpinnerAdapter.isEmpty() && !locationSpinnerAdapter.isSorted()) { + final LocationManager locationManager = (LocationManager) getApplicationContext().getSystemService(LOCATION_SERVICE); + locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, new BasicLocationListener(locationSpinnerAdapter, locationManager)); + android.location.Location currentLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + if (currentLocation != null) { + locationSpinnerAdapter.sortByDistance(currentLocation.getLatitude(), currentLocation.getLongitude()); + } + } + } + + private boolean mayRequestContacts() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return true; + } + if (checkSelfPermission(READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { + return true; + } + if (shouldShowRequestPermissionRationale(READ_CONTACTS)) { + Snackbar.make(mEmailView, R.string.contact_permission_rationale, Snackbar.LENGTH_INDEFINITE) + .setAction(android.R.string.ok, new View.OnClickListener() { + @Override + @TargetApi(Build.VERSION_CODES.M) + public void onClick(View v) { + requestPermissions(new String[]{READ_CONTACTS}, REQUEST_READ_CONTACTS); + } + }); + } else { + requestPermissions(new String[]{READ_CONTACTS}, REQUEST_READ_CONTACTS); + } + return false; + } + + private boolean mayRequestLocation() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return true; + } + if (checkSelfPermission(ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { + return true; + } + if (shouldShowRequestPermissionRationale(ACCESS_FINE_LOCATION)) { + Snackbar.make(mLocationView, R.string.location_permission_rationale, Snackbar.LENGTH_INDEFINITE) + .setAction(android.R.string.ok, new View.OnClickListener() { + @Override + @TargetApi(Build.VERSION_CODES.M) + public void onClick(View v) { + requestPermissions(new String[]{ACCESS_FINE_LOCATION}, REQUEST_ACCESS_FINE_LOCATION); + } + }); + } else { + requestPermissions(new String[]{ACCESS_FINE_LOCATION}, REQUEST_ACCESS_FINE_LOCATION); + } + return false; + } + + /** + * Callback received when a permissions request has been completed. + */ + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode == REQUEST_READ_CONTACTS) { + if (grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + populateAutoComplete(); + } + } else if (requestCode == REQUEST_ACCESS_FINE_LOCATION) { + if (grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + sortLocationList(); + } + } + } + + /** + * Attempts to sign in or register the account specified by the login form. + * If there are form errors (invalid email, missing fields, etc.), the + * errors are presented and no actual login attempt is made. + */ + private void attemptLogin() { + if (mAuthTask != null) { + return; + } + + // Reset errors. + mEmailView.setError(null); + mPasswordView.setError(null); + + // Store values at the time of the login attempt. + String email = mEmailView.getText().toString(); + String password = mPasswordView.getText().toString(); + Location location = (Location) mLocationView.getSelectedItem(); + + boolean cancel = false; + View focusView = null; + + // Check for a valid password, if the user entered one. + if (!TextUtils.isEmpty(password) && !isPasswordValid(password)) { + mPasswordView.setError(getString(R.string.error_invalid_password)); + focusView = mPasswordView; + cancel = true; + } + + // Check for a valid email address. + if (TextUtils.isEmpty(email)) { + mEmailView.setError(getString(R.string.error_field_required)); + focusView = mEmailView; + cancel = true; + } else if (!isEmailValid(email)) { + mEmailView.setError(getString(R.string.error_invalid_email)); + focusView = mEmailView; + cancel = true; + } + + if (cancel) { + // There was an error; don't attempt login and focus the first + // form field with an error. + focusView.requestFocus(); + } else { + // Show a progress spinner, and kick off a background task to + // perform the user login attempt. + showProgress(true); + mAuthTask = new UserLoginTask(email, password, location); + mAuthTask.execute((Void) null); + } + } + + private boolean isEmailValid(String email) { + return email.contains("@"); + } + + private boolean isPasswordValid(String password) { + return password.length() > 4; + } + + /** + * Shows the progress UI and hides the login form. + */ + @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR2) + private void showProgress(final boolean show) { + // On Honeycomb MR2 we have the ViewPropertyAnimator APIs, which allow + // for very easy animations. If available, use these APIs to fade-in + // the progress spinner. + int shortAnimTime = getResources().getInteger(android.R.integer.config_shortAnimTime); + + mLoginFormView.setVisibility(show ? View.GONE : View.VISIBLE); + mLoginFormView.animate().setDuration(shortAnimTime).alpha( + show ? 0 : 1).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mLoginFormView.setVisibility(show ? View.GONE : View.VISIBLE); + } + }); + + mProgressView.setVisibility(show ? View.VISIBLE : View.GONE); + mProgressView.animate().setDuration(shortAnimTime).alpha( + show ? 1 : 0).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mProgressView.setVisibility(show ? View.VISIBLE : View.GONE); + } + }); + } + + @Override + public Loader onCreateLoader(int i, Bundle bundle) { + return new CursorLoader(this, + // Retrieve data rows for the device user's 'profile' contact. + Uri.withAppendedPath(ContactsContract.Profile.CONTENT_URI, + ContactsContract.Contacts.Data.CONTENT_DIRECTORY), ProfileQuery.PROJECTION, + + // Select only email addresses. + ContactsContract.Contacts.Data.MIMETYPE + + " = ?", new String[]{ContactsContract.CommonDataKinds.Email + .CONTENT_ITEM_TYPE}, + + // Show primary email addresses first. Note that there won't be + // a primary email address if the user hasn't specified one. + ContactsContract.Contacts.Data.IS_PRIMARY + " DESC"); + } + + @Override + public void onLoadFinished(Loader cursorLoader, Cursor cursor) { + List emails = new ArrayList<>(); + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + emails.add(cursor.getString(ProfileQuery.ADDRESS)); + cursor.moveToNext(); + } + + addEmailsToAutoComplete(emails); + } + + @Override + public void onLoaderReset(Loader cursorLoader) { + + } + + private void addEmailsToAutoComplete(List emailAddressCollection) { + //Create adapter to tell the AutoCompleteTextView what to show in its dropdown list. + ArrayAdapter adapter = + new ArrayAdapter<>(LoginActivity.this, + android.R.layout.simple_dropdown_item_1line, emailAddressCollection); + + mEmailView.setAdapter(adapter); + } + + private interface ProfileQuery { + String[] PROJECTION = { + ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.CommonDataKinds.Email.IS_PRIMARY, + }; + + int ADDRESS = 0; + int IS_PRIMARY = 1; + } + + /** + * Represents an asynchronous login/registration task used to authenticate + * the user. + */ + public class UserLoginTask extends AsyncTask { + + private final String mEmail; + private final String mPassword; + private final Location mLocation; + private Exerciser mTempExerciser; + + UserLoginTask(String email, String password, Location location) { + mEmail = email; + mPassword = password; + mLocation = location; + } + + @Override + protected Boolean doInBackground(Void... params) { + mTempExerciser = mClient.login(mEmail, mPassword, mLocation); + + return (mTempExerciser != null); + } + + @Override + protected void onPostExecute(final Boolean success) { + mAuthTask = null; + showProgress(false); + + if (success) { + mExerciser = this.mTempExerciser; + launchMainActivity(); + } else { + mPasswordView.setError(getString(R.string.error_incorrect_password)); + mPasswordView.requestFocus(); + } + } + + @Override + protected void onCancelled() { + mAuthTask = null; + showProgress(false); + } + } + + /** + * Launches the main activity after an exerciser is retrieved + */ + private void launchMainActivity() { + if (mExerciser != null) { + ExerciserStore exerciserStore = new ExerciserStore(this); + exerciserStore.saveExerciser(mExerciser); + Intent nextIntent = new Intent(LoginActivity.this, WorkoutListActivity.class); + startActivity(nextIntent); + finish(); + } + } + + /** + * Represents an asynchronous task to retrieve locations from the api and populate + * the location spinner + */ + private class LocationListFetcher extends AsyncTask { + + private final android.location.Location mCurrentLocation; + private List mLocations; + + public LocationListFetcher() { + mCurrentLocation = null; + mLocations = null; + } + + public LocationListFetcher(android.location.Location currentLocation) { + mCurrentLocation = currentLocation; + } + + @Override + protected Boolean doInBackground(Void... params) { + mLocations = mClient.getLocationsWithDetail(); + return (mLocations != null); + } + + @Override + protected void onPostExecute(Boolean success) { + super.onPostExecute(success); + if (success) { + LocationSpinnerAdapter locationSpinnerAdapter = new LocationSpinnerAdapter(mLocations); + mLocationView.setAdapter(locationSpinnerAdapter); + + if (mCurrentLocation != null) { + locationSpinnerAdapter.sortByDistance(mCurrentLocation.getLatitude(), mCurrentLocation.getLongitude()); + } + + sortLocationList(); + } + } + } + + /** + * Adapter to show locations in spinner + */ + private class LocationSpinnerAdapter extends BaseAdapter implements SpinnerAdapter { + + private final List mLocations; + private boolean mSorted; + + public boolean isSorted() { + return mSorted; + } + + public LocationSpinnerAdapter(List locations) { + mLocations = locations; + mSorted = false; + } + + @Override + public int getCount() { + return mLocations.size(); + } + + @Override + public Object getItem(int position) { + return mLocations.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + TextView text; + if (convertView != null) { + text = (TextView) convertView; + } else { + text = (TextView) getLayoutInflater().inflate( + android.R.layout.simple_dropdown_item_1line, + parent, + false + ); + } + text.setTextColor(Color.BLACK); + text.setText(mLocations.get(position).getName()); + return text; + } + + /** + * Sorts the locations in the list by distance from a given point + * + * @param lat latitude of current location to calculate distance from + * @param lon longitude of current location to calculate distance from + */ + public void sortByDistance(double lat, double lon) { + Collections.sort(mLocations, new LocationDistanceComparator(lat, lon)); + mSorted = true; + } + } + + /** + * Comparator that allows sorting of locations by distance from a point + */ + static class LocationDistanceComparator implements Comparator { + + private final double mLat; + private final double mLon; + private final SparseArray mDistances; + + public LocationDistanceComparator(double lat, double lon) { + mLat = lat; + mLon = lon; + mDistances = new SparseArray<>(); + } + + private boolean locationHasCoords(Location l) { + return (l.hasAddress() && l.getAddress().hasLatitude() && l.getAddress().hasLongitude()); + } + + private float distanceToLocation(Location l) { + // Try to return from cache + float distance = mDistances.get(l.getId(), -1f); + if (distance > 0) { + return distance; + } + + // Calculate the distance between location and provided coordinates + float[] results = new float[2]; + android.location.Location.distanceBetween(mLat, mLon, l.getAddress().getLatitude(), l.getAddress().getLongitude(), results); + distance = results[0]; + // Store distance in cache + mDistances.put(l.getId(), distance); + + return distance; + } + + public int compare(Location l1, Location l2) { + // TODO: Fix bug here. Getting "Comparison method violates its general contract!" + if (locationHasCoords(l1) && !locationHasCoords(l2)) { + return -1; + } else if (!locationHasCoords(l1) && locationHasCoords(l2)) { + return 1; + } else if (locationHasCoords(l1) && locationHasCoords(l2)){ + float distance1 = distanceToLocation(l1); + float distance2 = distanceToLocation(l2); + + if (distance1 > distance2) { + return 1; + } else if (distance1 < distance2) { + return -1; + } + } + + // All else equal, let's use name + if (l1.getName() != null && l2.getName() != null) { + return l1.getName().compareTo(l2.getName()); + } + + return 0; + } + } + + private class BasicLocationListener implements LocationListener { + + private static final String TAG = "LocationListener"; + private final LocationSpinnerAdapter spinnerAdapter; + private final LocationManager locationManager; + + public BasicLocationListener(LocationSpinnerAdapter spinnerAdapter, LocationManager locationManager) { + this.spinnerAdapter = spinnerAdapter; + this.locationManager = locationManager; + } + + @Override + public void onLocationChanged(android.location.Location location) { + spinnerAdapter.sortByDistance(location.getLatitude(), location.getLongitude()); + locationManager.removeUpdates(this); + } + + @Override + public void onStatusChanged(String s, int i, Bundle bundle) { + Log.d(TAG, "onStatusChanged: "); + } + + @Override + public void onProviderEnabled(String s) { + Log.d(TAG, "onProviderEnabled: "); + } + + @Override + public void onProviderDisabled(String s) { + Log.d(TAG, "onProviderDisabled: "); + } + } +} + diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/PersistentCookieStore.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/PersistentCookieStore.java new file mode 100644 index 0000000..15d2596 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/PersistentCookieStore.java @@ -0,0 +1,148 @@ +package com.iamthefij.otbeta; +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 Lukas Zorich + * + * 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. + */ + +import android.content.Context; +import android.content.SharedPreferences; + +import com.google.gson.Gson; + +import java.net.CookieManager; +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.URI; +import java.util.List; + +/** + * Repository for cookies. CookieManager will store cookies of every incoming HTTP response into + * CookieStore, and retrieve cookies for every outgoing HTTP request. + *

+ * Cookies are stored in {@link android.content.SharedPreferences} and will persist on the + * user's device between application session. {@link com.google.gson.Gson} is used to serialize + * the cookies into a json string in order to be able to save the cookie to + * {@link android.content.SharedPreferences} + *

+ * Created by lukas on 17-11-14. + */ +public class PersistentCookieStore implements CookieStore { + + /** + * The default preferences string. + */ + private final static String PREF_DEFAULT_STRING = ""; + + /** + * The preferences name. + */ + private final static String PREFS_NAME = PersistentCookieStore.class.getName(); + + /** + * The preferences session cookie key. + */ + private final static String PREF_SESSION_COOKIE = "session_cookie"; + + /** + * Session ID cookie name + */ + private final static String SESSION_COOKIE_NAME = "JSESSIONID"; + + private final CookieStore mStore; + private final Context mContext; + + /** + * @param context The application context + */ + public PersistentCookieStore(Context context) { + // prevent context leaking by getting the application context + mContext = context.getApplicationContext(); + + // get the default in memory store and if there is a cookie stored in shared preferences, + // we added it to the cookie store + mStore = new CookieManager().getCookieStore(); + String jsonSessionCookie = getJsonSessionCookieString(); + if (!jsonSessionCookie.equals(PREF_DEFAULT_STRING)) { + Gson gson = new Gson(); + HttpCookie cookie = gson.fromJson(jsonSessionCookie, HttpCookie.class); + mStore.add(URI.create(cookie.getDomain()), cookie); + } + } + + @Override + public void add(URI uri, HttpCookie cookie) { + if (cookie.getName().equals(SESSION_COOKIE_NAME)) { + // if the cookie that the cookie store attempt to add is a session cookie, + // we remove the older cookie and save the new one in shared preferences + remove(URI.create(cookie.getDomain()), cookie); + saveSessionCookie(cookie); + } + + mStore.add(URI.create(cookie.getDomain()), cookie); + } + + @Override + public List get(URI uri) { + return mStore.get(uri); + } + + @Override + public List getCookies() { + return mStore.getCookies(); + } + + @Override + public List getURIs() { + return mStore.getURIs(); + } + + @Override + public boolean remove(URI uri, HttpCookie cookie) { + return mStore.remove(uri, cookie); + } + + @Override + public boolean removeAll() { + return mStore.removeAll(); + } + + private String getJsonSessionCookieString() { + return getPrefs().getString(PREF_SESSION_COOKIE, PREF_DEFAULT_STRING); + } + + /** + * Saves the HttpCookie to SharedPreferences as a json string. + * + * @param cookie The cookie to save in SharedPreferences. + */ + private void saveSessionCookie(HttpCookie cookie) { + Gson gson = new Gson(); + String jsonSessionCookieString = gson.toJson(cookie); + SharedPreferences.Editor editor = getPrefs().edit(); + editor.putString(PREF_SESSION_COOKIE, jsonSessionCookieString); + editor.apply(); + } + + private SharedPreferences getPrefs() { + return mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/SplashActivity.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/SplashActivity.java new file mode 100644 index 0000000..4f788ac --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/SplashActivity.java @@ -0,0 +1,31 @@ +package com.iamthefij.otbeta; + +import android.content.Intent; +import android.support.v7.app.AppCompatActivity; +import android.os.Bundle; + +import java.net.HttpCookie; +import java.util.List; + +public class SplashActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_splash); + + // Check for existing cookies + PersistentCookieStore cookieStore = new PersistentCookieStore(this); + List cookies = cookieStore.getCookies(); + + // Set next intent based on existing cookies + Intent nextIntent; + if (cookies.isEmpty()) { + nextIntent = new Intent(SplashActivity.this, LoginActivity.class); + } else { + nextIntent = new Intent(SplashActivity.this, WorkoutListActivity.class); + } + startActivity(nextIntent); + finish(); + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutDetailActivity.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutDetailActivity.java new file mode 100644 index 0000000..ff430ea --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutDetailActivity.java @@ -0,0 +1,73 @@ +package com.iamthefij.otbeta; + +import android.content.Intent; +import android.os.Bundle; +import android.support.v7.widget.Toolbar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.app.ActionBar; +import android.view.MenuItem; + +/** + * An activity representing a single Workout detail screen. This + * activity is only used narrow width devices. On tablet-size devices, + * item details are presented side-by-side with a list of items + * in a {@link WorkoutListActivity}. + */ +public class WorkoutDetailActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_workout_detail); + Toolbar toolbar = (Toolbar) findViewById(R.id.detail_toolbar); + setSupportActionBar(toolbar); + + // Show the Up button in the action bar. + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + + // savedInstanceState is non-null when there is fragment state + // saved from previous configurations of this activity + // (e.g. when rotating the screen from portrait to landscape). + // In this case, the fragment will automatically be re-added + // to its container so we don't need to manually add it. + // For more information, see the Fragments API guide at: + // + // http://developer.android.com/guide/components/fragments.html + // + if (savedInstanceState == null) { + // Create the detail fragment and add it to the activity + // using a fragment transaction. + Bundle arguments = new Bundle(); + arguments.putString(WorkoutDetailFragment.ARG_EXERCISER_UUID, + getIntent().getStringExtra(WorkoutDetailFragment.ARG_EXERCISER_UUID)); + arguments.putInt(WorkoutDetailFragment.ARG_WORKOUT_ID, + getIntent().getIntExtra(WorkoutDetailFragment.ARG_WORKOUT_ID, -1)); + arguments.putString(WorkoutDetailFragment.ARG_WORKOUT_TITLE, + getIntent().getStringExtra(WorkoutDetailFragment.ARG_WORKOUT_TITLE)); + WorkoutDetailFragment fragment = new WorkoutDetailFragment(); + fragment.setArguments(arguments); + getSupportFragmentManager().beginTransaction() + .add(R.id.workout_detail_container, fragment) + .commit(); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + if (id == android.R.id.home) { + // This ID represents the Home or Up button. In the case of this + // activity, the Up button is shown. For + // more details, see the Navigation pattern on Android Design: + // + // http://developer.android.com/design/patterns/navigation.html#up-vs-back + // + navigateUpTo(new Intent(this, WorkoutListActivity.class)); + return true; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutDetailFragment.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutDetailFragment.java new file mode 100644 index 0000000..84bc7b2 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutDetailFragment.java @@ -0,0 +1,230 @@ +package com.iamthefij.otbeta; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Color; +import android.os.AsyncTask; +import android.support.design.widget.CollapsingToolbarLayout; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.goebl.david.Response; +import com.goebl.david.WebbException; +import com.jjoe64.graphview.DefaultLabelFormatter; +import com.jjoe64.graphview.GraphView; +import com.jjoe64.graphview.GridLabelRenderer; +import com.jjoe64.graphview.ValueDependentColor; +import com.jjoe64.graphview.series.BarGraphSeries; +import com.jjoe64.graphview.series.DataPoint; +import com.jjoe64.graphview.series.LineGraphSeries; +import com.iamthefij.otbeta.api.Client; +import com.iamthefij.otbeta.api.Exerciser; +import com.iamthefij.otbeta.api.Interval; +import com.iamthefij.otbeta.api.Workout; + +import java.util.List; + +/** + * A fragment representing a single Workout detail screen. + * This fragment is either contained in a {@link WorkoutListActivity} + * in two-pane mode (on tablets) or a {@link WorkoutDetailActivity} + * on handsets. + */ +public class WorkoutDetailFragment extends Fragment { + /** + * The fragment argument representing the item ID that this fragment + * represents. + */ + public static final String ARG_EXERCISER_UUID = "exerciser_uuid"; + public static final String ARG_WORKOUT_ID = "workout_id"; + public static final String ARG_WORKOUT_TITLE = "workout_TITLE"; + + private Client mClient; + private Exerciser mExerciser; + private Interval mInterval; + private Workout mWorkout; + private View mRootView; + private String mWorkoutTitle; + + /** + * Mandatory empty constructor for the fragment manager to instantiate the + * fragment (e.g. upon screen orientation changes). + */ + public WorkoutDetailFragment() { + } + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mClient = new Client(this.getContext()); + + if (getArguments().containsKey(ARG_EXERCISER_UUID)) { + // TODO: In a real-world scenario, use a Loader + // to load content from a content provider. + mExerciser = new Exerciser(getArguments().getString(ARG_EXERCISER_UUID)); + mInterval = new Interval(getArguments().getInt(ARG_WORKOUT_ID)); + mWorkoutTitle = getArguments().getString(ARG_WORKOUT_TITLE); + } + + new WorkoutDetailGetter().execute(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + mRootView = inflater.inflate(R.layout.workout_detail, container, false); + + maybeSetRootView(); + setTitle(mWorkoutTitle); + + return mRootView; + } + + private void setTitle(String workoutTitle) { + Activity activity = this.getActivity(); + CollapsingToolbarLayout appBarLayout = (CollapsingToolbarLayout) activity.findViewById(R.id.toolbar_layout); + if (appBarLayout != null) { + appBarLayout.setTitle(workoutTitle); + } + } + + private void maybeSetRootView() { + if (mRootView != null && mWorkout != null) { + fillHeartRateZoneGraph((GraphView) mRootView.findViewById(R.id.workout_hr_zone_graph)); + fillHeartRateGraph((GraphView) mRootView.findViewById(R.id.workout_hrm_graph)); + fillStats(mRootView.findViewById(R.id.workout_detail_insert)); + } + } + + private void fillStats(View view) { + TextView splatPointsView = (TextView) view.findViewById(R.id.workout_detail_splat_points); + splatPointsView.setText(String.valueOf(mWorkout.getPoints())); + + TextView caloriesView = (TextView) view.findViewById(R.id.workout_detail_calories); + caloriesView.setText(String.valueOf(mWorkout.getCalories())); + + TextView avgHrView = (TextView) view.findViewById(R.id.workout_detail_avg_hr); + avgHrView.setText(String.valueOf(mWorkout.getHeartRate().getAverage())); + + TextView durationView = (TextView) view.findViewById(R.id.workout_detail_duration); + durationView.setText(String.valueOf(mWorkout.getDuration()/60)); + } + + private void fillHeartRateZoneGraph(GraphView graphView) { + Workout.HeartRateZones zones = mWorkout.getHrm(); + + BarGraphSeries series = new BarGraphSeries<>( + new DataPoint[] { + new DataPoint(0, zones.getGreyZoneSeconds()/60), + new DataPoint(1, zones.getBlueZoneSeconds()/60), + new DataPoint(2, zones.getGreenZoneSeconds()/60), + new DataPoint(3, zones.getOrangeZoneSeconds()/60), + new DataPoint(4, zones.getRedZoneSeconds()/60) + } + ); + series.setAnimated(true); + series.setDrawValuesOnTop(true); + series.setValuesOnTopColor(R.color.colorPrimary); + series.setValueDependentColor(new ValueDependentColor() { + @Override + public int get(DataPoint data) { + switch ((int) data.getX()) { + case 0: + return Color.GRAY; + case 1: + return Color.BLUE; + case 2: + return Color.GREEN; + case 3: + return Color.rgb(255, 165, 0); + case 4: + return Color.RED; + + } + return Color.DKGRAY; + } + }); + + graphView.getGridLabelRenderer().setHorizontalLabelsVisible(false); + graphView.getGridLabelRenderer().setGridStyle(GridLabelRenderer.GridStyle.HORIZONTAL); + graphView.getViewport().setXAxisBoundsManual(true); + graphView.getViewport().setMinX(-0.5); + graphView.getViewport().setMaxX(4.5); + graphView.addSeries(series); + } + + private void fillHeartRateGraph(GraphView graphView) { + Workout.Metric heartRate = mWorkout.getHeartRate(); + List values = heartRate.getValues(); + + double time = 0.0; + int numIntervals = values.size(); + int intervalLength = heartRate.getInterval(); + int targetHeartRate = mWorkout.getHrm().getTargetHeartRate(); + double percentOfTarget; + DataPoint[] dataPoints = new DataPoint[numIntervals]; + for (int i = 0; i < numIntervals; i++) { + percentOfTarget = (values.get(i) / targetHeartRate) * 100; + dataPoints[i] = new DataPoint(time, percentOfTarget); + time += intervalLength; + } + + graphView.getGridLabelRenderer().setVerticalAxisTitle("Heart Rate"); + graphView.getGridLabelRenderer().setHorizontalAxisTitle("Minutes"); + graphView.getGridLabelRenderer().setLabelFormatter(new DefaultLabelFormatter() { + @Override + public String formatLabel(double value, boolean isValueX) { + if (isValueX) { + // Convert to minutes + value = value / 60; + } + return super.formatLabel(value, isValueX); + } + }); + graphView.addSeries(new LineGraphSeries<>(dataPoints)); + } + + /** + * Represents an asynchronous task to retrieve workouts from the api and populate + * the recycle view adapter + */ + private class WorkoutDetailGetter extends AsyncTask { + + private Workout mWorkoutTemp; + private boolean mInvalidSession = false; + + @Override + protected Boolean doInBackground(Void... params) { + try { + mWorkoutTemp = mClient.getWorkout(mExerciser, mInterval); + } catch (WebbException ex) { + Response response = ex.getResponse(); + if (response.getStatusCode() == 403) { + mInvalidSession = true; + return false; + } else { + throw ex; + } + } + return true; + } + + @Override + protected void onPostExecute(Boolean success) { + super.onPostExecute(success); + if (success) { + mWorkout = mWorkoutTemp; + maybeSetRootView(); + } else if (mInvalidSession) { + Intent loginActivity = new Intent(getActivity(), LoginActivity.class); + startActivity(loginActivity); + } + } + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutListActivity.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutListActivity.java new file mode 100644 index 0000000..ef4b472 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/WorkoutListActivity.java @@ -0,0 +1,262 @@ +package com.iamthefij.otbeta; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.net.http.HttpResponseCache; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + + +import com.goebl.david.Response; +import com.goebl.david.WebbException; +import com.iamthefij.otbeta.api.Client; +import com.iamthefij.otbeta.api.Exerciser; +import com.iamthefij.otbeta.api.Interval; + +import java.io.File; +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * An activity representing a list of Workouts. This activity + * has different presentations for handset and tablet-size devices. On + * handsets, the activity presents a list of items, which when touched, + * lead to a {@link WorkoutDetailActivity} representing + * item details. On tablets, the activity presents the list of items and + * item details side-by-side using two vertical panes. + */ +public class WorkoutListActivity extends AppCompatActivity { + + private static final String TAG = "WorkoutListActivity"; + /** + * Whether or not the activity is in two-pane mode, i.e. running on a tablet + * device. + */ + private boolean mTwoPane; + private Client mClient; + private Exerciser mExerciser; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_workout_list); + + // Install cache to prevent HTTP requests every time a view is revisited + installHttpCache(); + + // Initialize client + mClient = new Client(this); + + // Get Exerciser from store + ExerciserStore exerciserStore = new ExerciserStore(this); + mExerciser = exerciserStore.getExerciser(); + + // Get + RecyclerView recyclerView = (RecyclerView) findViewById(R.id.workout_list); + new WorkoutListGetter(recyclerView).execute(); + + // Set up the toolbar + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + toolbar.setTitle(getTitle()); + + // Determine if dual pane + if (findViewById(R.id.workout_detail_container) != null) { + // The detail container view will be present only in the + // large-screen layouts (res/values-w900dp). + // If this view is present, then the + // activity should be in two-pane mode. + mTwoPane = true; + } + } + + @Override + protected void onStop() { + super.onStop(); + flushHttpCache(); + } + + private void installHttpCache() { + try { + File httpCacheDir = new File(this.getCacheDir(), "http"); + long httpCacheSize = 10 * 1024 * 1024; // 10 MiB + HttpResponseCache.install(httpCacheDir, httpCacheSize); + } catch (IOException e) { + Log.i(TAG, "HTTP response cache installation failed:" + e); + } + } + + private void flushHttpCache() { + HttpResponseCache cache = HttpResponseCache.getInstalled(); + if (cache != null) { + cache.flush(); + } + } + + /** + * Represents an asynchronous task to retrieve workouts from the api and populate + * the recycle view adapter + */ + private class WorkoutListGetter extends AsyncTask { + + private final RecyclerView mRecyclerView; + private List mWorkouts; + private boolean mInvalidSession = false; + + WorkoutListGetter(RecyclerView recyclerView) { + mRecyclerView = recyclerView; + } + + @Override + protected Boolean doInBackground(Void... params) { + try { + mWorkouts = mClient.getWorkouts(mExerciser, 30); + } catch (WebbException ex) { + Response response = ex.getResponse(); + if (response.getStatusCode() == 403) { + mInvalidSession = true; + return false; + } else { + throw ex; + } + } + return mWorkouts != null; + } + + @Override + protected void onPostExecute(Boolean success) { + super.onPostExecute(success); + if (success) { + WorkoutRecyclerAdapter adapter = new WorkoutRecyclerAdapter(mWorkouts); + mRecyclerView.setAdapter(adapter); + } else if (mInvalidSession) { + Intent loginActivity = new Intent(WorkoutListActivity.this, LoginActivity.class); + startActivity(loginActivity); + } + } + } + + /** + * Adapter to display workouts as a list of cards + */ + private class WorkoutRecyclerAdapter + extends RecyclerView.Adapter { + + private final List mWorkouts; + + WorkoutRecyclerAdapter(List workouts) { + mWorkouts = workouts; + } + + @Override + public WorkoutViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.workout_card_view, parent, false); + return new WorkoutViewHolder(view); + } + + @Override + public void onBindViewHolder(final WorkoutViewHolder holder, int position) { + Interval workout = mWorkouts.get(position); + if (workout != null) { + holder.fillView(workout); + + holder.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mTwoPane) { + Bundle arguments = new Bundle(); + arguments.putString(WorkoutDetailFragment.ARG_EXERCISER_UUID, mExerciser.getUuid()); + arguments.putInt(WorkoutDetailFragment.ARG_WORKOUT_ID, holder.getWorkout().getId()); + arguments.putString(WorkoutDetailFragment.ARG_WORKOUT_TITLE, holder.getTitle()); + WorkoutDetailFragment fragment = new WorkoutDetailFragment(); + fragment.setArguments(arguments); + getSupportFragmentManager().beginTransaction() + .replace(R.id.workout_detail_container, fragment) + .commit(); + } else { + Context context = v.getContext(); + Intent intent = new Intent(context, WorkoutDetailActivity.class); + intent.putExtra(WorkoutDetailFragment.ARG_EXERCISER_UUID, mExerciser.getUuid()); + intent.putExtra(WorkoutDetailFragment.ARG_WORKOUT_ID, holder.getWorkout().getId()); + intent.putExtra(WorkoutDetailFragment.ARG_WORKOUT_TITLE, holder.getTitle()); + + context.startActivity(intent); + } + } + }); + } + } + + @Override + public int getItemCount() { + return mWorkouts.size(); + } + + class WorkoutViewHolder extends RecyclerView.ViewHolder { + private final View mView; + private final TextView mWorkoutDateView; + private final TextView mWorkoutCaloriesView; + private final TextView mWorkoutMinutesView; + private final TextView mWorkoutPointsView; + private Interval mWorkout; + + Interval getWorkout() { + return mWorkout; + } + + WorkoutViewHolder(View view) { + super(view); + mView = view; + mWorkoutDateView = (TextView) mView.findViewById(R.id.workout_date); + mWorkoutCaloriesView = (TextView) mView.findViewById(R.id.workout_calories); + mWorkoutMinutesView = (TextView) mView.findViewById(R.id.workout_minutes); + mWorkoutPointsView = (TextView) mView.findViewById(R.id.workout_points); + } + + @SuppressLint("DefaultLocale") + void fillView(Interval workout) { + mWorkout = workout; + + String workoutDate = "Unknown"; + String localIsoTime = workout.getStartDateLocal(); + if (localIsoTime != null) { + SimpleDateFormat isoParser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US); + SimpleDateFormat formatter = new SimpleDateFormat("MMM d, yyyy", Locale.US); + try { + Date localIso = isoParser.parse(localIsoTime); + workoutDate = formatter.format(localIso); + } catch (ParseException e) { + e.printStackTrace(); + } + } + + mWorkoutDateView.setText(workoutDate); + mWorkoutCaloriesView.setText(String.format("%d Calories", workout.getTotalCalories())); + mWorkoutMinutesView.setText(String.format("%d minutes", workout.getTotalDuration()/60)); + mWorkoutPointsView.setText(String.format("%d Splat Points", workout.getTotalPoints())); + } + + void setOnClickListener(View.OnClickListener listener) { + mView.setOnClickListener(listener); + } + + String getTitle() { + return (String) mWorkoutDateView.getText(); + } + } + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Client.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Client.java new file mode 100644 index 0000000..ff20725 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Client.java @@ -0,0 +1,204 @@ +package com.iamthefij.otbeta.api; + +import android.content.Context; +import android.net.http.HttpResponseCache; +import android.util.Log; + +import com.goebl.david.Webb; +import com.iamthefij.otbeta.PersistentCookieStore; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + + +/** + * Simple client for communicating with netpulse + */ +public class Client { + private static final String BASE_URL = "https://orangetheoryfitness.netpulse.com/np"; + private static final String TAG = "OTBeta"; + private final Webb webb; + + public Client(Context context) { + webb = Webb.create(); + webb.setBaseUri(BASE_URL); + + // Session id is stored in a cookie so this is used to persist the logged in session + CookieManager cookieManager = new CookieManager(new PersistentCookieStore(context), CookiePolicy.ACCEPT_ALL); + CookieHandler.setDefault(cookieManager); + } + + /** + * Returns a list of all OT locations along with Uuids + * + * @return List of {@link Location} instances with minimal information + */ + public List getLocations() { + return getLocations("simple"); + } + + /** + * Returns a list of all OT locations along with Uuids and Address information + * + * @return List of {@link Location} with detailed address information + */ + public List getLocationsWithDetail() { + return getLocations("detail"); + } + + private List getLocations(String responseType) { + JSONArray response = webb.get("/company/children") + .param("responseType", responseType) + .param("partnerAlias", "clubComq") + .ensureSuccess() + .asJsonArray() + .getBody(); + + JSONObject location; + List locations = new ArrayList<>(response.length()); + for (int i = 0; i < response.length(); i++) { + location = response.optJSONObject(i); + if (location != null) { + locations.add(new Location(location)); + } + } + Log.d(TAG, String.format("getLocations: Added %d locations to list", locations.size())); + return locations; + } + + /** + * Authenticates a user and returns their information + * + * User session is stored as a cookie + * + * @param username Email address of the user to authenticate + * @param password Password for the authenticating user + * @param homeClub {@link Location} instance of the users home club + * @return {@link Exerciser} representing the authenticated user or null + */ + public Exerciser login(String username, String password, Location homeClub) { + return login(username, password, homeClub.getUuid()); + } + + private Exerciser login(String username, String password, String homeClubUuid) { + JSONObject response = webb.post("/exerciser/login") + .param("username", username) + .param("password", password) + .param("homeClubUuid", homeClubUuid) + .ensureSuccess() + .asJsonObject() + .getBody(); + + return new Exerciser(response); + } + + /** + * Gets the full profile for a given Exerciser + * + * @param exerciser usr to retrieve profile for + * @return ExerciserProfile instance with extended user data + */ + public ExerciserProfile getExerciserProfile(Exerciser exerciser) { + JSONObject response = webb.get("/exerciser/" + exerciser.getUuid()) + .ensureSuccess() + .asJsonObject() + .getBody(); + + return new ExerciserProfile(response); + } + + /** + * Gets a monthly summary for an exerciser for a given number of months from the current date + * + * @param exerciser {@link Exerciser} instance of a user to get summary for + * @param months max number of months to return + * @return list of {@link Interval} results containing the summaries + */ + public List getMonthlyWorkoutSummary(Exerciser exerciser, int months) { + return getWorkouts(exerciser.getUuid(), null, null, months, "month"); + } + + /** + * Gets list of recent individual workout summaries for an exerciser from the current date + * + * @param exerciser {@link Exerciser} instance of a user to get summary for + * @param count max number of workouts to return + * @return list of {@link Interval} results containing the summaries + */ + public List getWorkouts(Exerciser exerciser, int count) { + return getWorkouts(exerciser.getUuid(), null, null, count, "single"); + } + + private List getWorkouts(String exerciserUuid, String filter, String endDate, Integer intervalCount, String interval) { + // Set default values + if (filter == null) filter = "all"; + if (endDate == null) endDate = "20450101"; // Arbitrarily future date + if (intervalCount == null) intervalCount = 10; + // "single" "month" + if (interval == null) filter = "single"; + + JSONObject response = webb.get("/exerciser/" + exerciserUuid + "/workouts") + .param("filter", filter) + .param("endDate", endDate) + .param("intervalCount", intervalCount) + .param("interval", interval) + .ensureSuccess() + .asJsonObject() + .getBody(); + + Interval.setSpeedUnit(response.optString("speedUnit")); + Interval.setDistanceUnit(response.optString("distanceUnit")) ; + JSONArray intervalResponse = response.optJSONArray("intervals"); + if (intervalResponse != null) { + List intervals = new ArrayList<>(intervalResponse.length()); + for (int i = 0; i < intervalResponse.length(); i++) { + JSONObject intervalData = intervalResponse.optJSONObject(i); + if (intervalData != null) { + intervals.add(new Interval(intervalData)); + } + } + Log.d(TAG, String.format("getWorkouts: Added %d intervals to list", intervals.size())); + return intervals; + } else { + Log.i(TAG, "getWorkouts: Could not parse workout intervals"); + } + + return null; + } + + /** + * Gets all details for a given workout + * + * This is where most of the interesting data is including all metrics for HR + * + * @param exerciser {@link Exerciser} instance of a user workout for + * @param interval the {@link Interval} to retrieve details of + * @return a {@link Workout} instance containing all details and metrics + */ + public Workout getWorkout(Exerciser exerciser, Interval interval) { + return getWorkout(exerciser.getUuid(), interval.getId()); + } + + private Workout getWorkout(String exerciserUuid, int intervalId) { + JSONObject response = webb.get("/exerciser/" + exerciserUuid + "/workout/" + String.valueOf(intervalId)) + .ensureSuccess() + .useCaches(true) + .asJsonObject() + .getBody(); + + HttpResponseCache cache = HttpResponseCache.getInstalled(); + if (cache != null) { + Log.d(TAG, String.format(Locale.US, "getWorkout: Got location. Cache results: hit %d, total requests %d", cache.getHitCount(), cache.getRequestCount())); + } else { + Log.d(TAG, "getWorkout: Got location. No installed cache."); + } + return new Workout(response); + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Exerciser.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Exerciser.java new file mode 100644 index 0000000..21a4bb9 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Exerciser.java @@ -0,0 +1,139 @@ +package com.iamthefij.otbeta.api; + +import org.json.JSONObject; + +/** + * Represents an Exerciser or user + */ +public class Exerciser { + + private final String mUuid; + private final String mFirstName; + private final String mLastName; + private final Boolean mVerified; + private final String mHomeClubUuid; + private final String mHomeClubName; + private final String mChainUuid; + private final String mChainName; + private final String mTimezone; + private final Integer mTimezoneOffset; + private final Boolean mProfileCompleted; + private final String mMembershipType; + private final String mBarcode; + private final String mExternalAuthToken; + private final String mMeasurementUnit; + private final Boolean mHasMessages; + + public String getUuid() { + return mUuid; + } + + public String getFirstName() { + return mFirstName; + } + + public String getLastName() { + return mLastName; + } + + public Boolean getVerified() { + return mVerified; + } + + public String getHomeClubUuid() { + return mHomeClubUuid; + } + + public String getHomeClubName() { + return mHomeClubName; + } + + public String getChainUuid() { + return mChainUuid; + } + + public String getChainName() { + return mChainName; + } + + public String getTimezone() { + return mTimezone; + } + + public Integer getTimezoneOffset() { + return mTimezoneOffset; + } + + public Boolean getProfileCompleted() { + return mProfileCompleted; + } + + public String getMembershipType() { + return mMembershipType; + } + + public String getBarcode() { + return mBarcode; + } + + public String getExternalAuthToken() { + return mExternalAuthToken; + } + + public String getMeasurementUnit() { + return mMeasurementUnit; + } + + public Boolean getHasMessages() { + return mHasMessages; + } + + /** + * Creates an Exerciser from a login response + * + * @param loginResponse {@link JSONObject} response from a login request + */ + public Exerciser(JSONObject loginResponse) { + mUuid = loginResponse.optString("uuid"); + mFirstName = loginResponse.optString("firstName"); + mLastName = loginResponse.optString("lastName"); + mVerified = loginResponse.optBoolean("verified"); + mHomeClubUuid = loginResponse.optString("homeClubUuid"); + mHomeClubName = loginResponse.optString("homeClubName"); + mChainUuid = loginResponse.optString("chainUuid"); + mChainName = loginResponse.optString("chainName"); + mTimezone = loginResponse.optString("timezone"); + mTimezoneOffset = loginResponse.optInt("timezoneOffset"); + mProfileCompleted = loginResponse.optBoolean("profileCompleted"); + mMembershipType = loginResponse.optString("membershipType"); + mBarcode = loginResponse.optString("barcode"); + mExternalAuthToken = loginResponse.optString("externalAuthToken"); + mMeasurementUnit = loginResponse.optString("measurementUnit"); + mHasMessages = loginResponse.optBoolean("hasMessages"); + } + + /** + * Minimal version of Exerciser that provides Uuid for REST calls + * + * @param uuid {@link String} Uuid of the Exerciser to be instantiated + */ + public Exerciser(String uuid) { + mUuid = uuid; + mFirstName = null; + mLastName = null; + mVerified = null; + mHomeClubUuid = null; + mHomeClubName = null; + mChainUuid = null; + mChainName = null; + mTimezone = null; + mTimezoneOffset = null; + mProfileCompleted = null; + mMembershipType = null; + mBarcode = null; + mExternalAuthToken = null; + mMeasurementUnit = null; + mHasMessages = null; + } + +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/ExerciserProfile.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/ExerciserProfile.java new file mode 100644 index 0000000..f2b2713 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/ExerciserProfile.java @@ -0,0 +1,119 @@ +package com.iamthefij.otbeta.api; + +import android.location.Address; + +import org.json.JSONObject; + +import java.util.Locale; + +/** + * Full profile of an Exerciser or user + */ + +public class ExerciserProfile { + private final String mAboutMe; + private final Boolean mActive; + private Address mAddress; + private final String mBarcode; + private final String mBirthday; + private final String mClientLoginId; + private final String mCreatedAt; + private final String mEmail; + private EmailPreference mEmailPreference; + private final JSONObject mExternalServiceNames; + private final Boolean mFacebookAutoShare; + private final String mFirstName; + private final String mLastName; + private final String mGender; + private final Double mHeight; + private final String mHomeClubUuid; + private final String mMeasurementUnit; + private final String mNickname; + private final String mPasscode; + private final String mPhoneNumber; + private final String mPicturePassword; + private final String mPrivacy; + private final String mProfilePicture; + private final String mTimezone; + private final String mUpdatedAt; + private final String mUuid; + private final Boolean mVerified; + private final Double mWeight; + + public ExerciserProfile(JSONObject response) { + mAboutMe = response.optString("aboutMe"); + mActive = response.optBoolean("active"); + mBarcode = response.optString("barcode"); + mBirthday = response.optString("birthday"); + mClientLoginId = response.optString("clientLoginId"); + mCreatedAt = response.optString("createdAt"); + mEmail = response.optString("email"); + mExternalServiceNames = response.optJSONObject("externalServiceNames"); + mFacebookAutoShare = response.optBoolean("facebookAutoShare"); + mFirstName = response.optString("firstName"); + mLastName = response.optString("lastName"); + mGender = response.optString("gender"); + mHomeClubUuid = response.optString("homeClubUuid"); + mMeasurementUnit = response.optString("measurementUnit"); + mNickname = response.optString("nickname"); + mPasscode = response.optString("passcode"); + mPhoneNumber = response.optString("phoneNumber"); + mPicturePassword = response.optString("picturePassword"); + mPrivacy = response.optString("privacy"); + mProfilePicture = response.optString("profilePicture"); + mTimezone = response.optString("timezone"); + mUpdatedAt = response.optString("updatedAt"); + mUuid = response.optString("uuid"); + mVerified = response.optBoolean("verified"); + mHeight = response.optDouble("height"); + mWeight = response.optDouble("weight"); + + JSONObject address = response.optJSONObject("address"); + if (address != null) { + mAddress = new Address(Locale.US); + mAddress.setAddressLine(1, address.optString("street1")); + mAddress.setAddressLine(2, address.optString("street2")); + mAddress.setLocality(address.optString("city")); + mAddress.setCountryCode(address.optString("country")); + mAddress.setPostalCode(address.optString("postalCode")); + mAddress.setAdminArea(address.optString("stateOrProvince")); + } + + JSONObject emailPreference = response.optJSONObject("emailPreference"); + if (emailPreference != null) { + mEmailPreference = new EmailPreference(emailPreference); + } + } + + + /** + * Email preferences for a user + */ + public class EmailPreference { + private final Boolean mEmailApplause; + private final Boolean mEmailChallengeNotice; + private final Boolean mEmailComment; + private final Boolean mEmailGoalNotice; + private final Boolean mEmailMonthlyWorkout; + private final Boolean mEmailSingleWorkout; + private final Boolean mEmailSystemMessage; + private final Boolean mEmailTrainerMessage; + private final Boolean mEmailWeeklyWorkout; + + /** + * Generates the preferences instance for a given user based on JSON response + * @param preference {@link JSONObject} to build preferences from + */ + public EmailPreference(JSONObject preference) { + mEmailApplause = preference.optBoolean("emailApplause"); + mEmailChallengeNotice = preference.optBoolean("emailChallengeNotice"); + mEmailComment = preference.optBoolean("emailComment"); + mEmailGoalNotice = preference.optBoolean("emailGoalNotice"); + mEmailMonthlyWorkout = preference.optBoolean("emailMonthlyWorkout"); + mEmailSingleWorkout = preference.optBoolean("emailSingleWorkout"); + mEmailSystemMessage = preference.optBoolean("emailSystemMessage"); + mEmailTrainerMessage = preference.optBoolean("emailTrainerMessage"); + mEmailWeeklyWorkout = preference.optBoolean("emailWeeklyWorkout"); + } + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Interval.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Interval.java new file mode 100644 index 0000000..e9e7bc2 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Interval.java @@ -0,0 +1,156 @@ +package com.iamthefij.otbeta.api; + +import org.json.JSONObject; + +/** + * Interval is a workout summary + */ + +public class Interval { + private static String sSpeedUnit; + private static String sDistanceUnit; + + private Double mAvgHeartRate; + private Double mAvgResistance; + private Double mAvgSpeed; + private String mPointsLabel; + private String mStartDateLocal; + private Double mStartDateUtc; + private Integer mTotalCalories; + private Double mTotalDistance; + private Integer mTotalDuration; + private Integer mTotalPoints; + private Integer mTotalWorkout; + + private String mDeviceType; + private String mEquipmentType; + private final Integer mId; + private Boolean mNoted; + private String mTimezone; + private String mWorkoutCategory; + private String mWorkoutSource; + + public static String getSpeedUnit() { + return sSpeedUnit; + } + + public static void setSpeedUnit(String sSpeedUnit) { + Interval.sSpeedUnit = sSpeedUnit; + } + + public static String getDistanceUnit() { + return sDistanceUnit; + } + + public static void setDistanceUnit(String sDistanceUnit) { + Interval.sDistanceUnit = sDistanceUnit; + } + + public Double getAvgHeartRate() { + return mAvgHeartRate; + } + + public Double getAvgResistance() { + return mAvgResistance; + } + + public Double getAvgSpeed() { + return mAvgSpeed; + } + + public String getPointsLabel() { + return mPointsLabel; + } + + public String getStartDateLocal() { + return mStartDateLocal; + } + + public Double getStartDateUtc() { + return mStartDateUtc; + } + + public Integer getTotalCalories() { + return mTotalCalories; + } + + public Double getTotalDistance() { + return mTotalDistance; + } + + public Integer getTotalDuration() { + return mTotalDuration; + } + + public Integer getTotalPoints() { + return mTotalPoints; + } + + public Integer getTotalWorkout() { + return mTotalWorkout; + } + + public String getDeviceType() { + return mDeviceType; + } + + public String getEquipmentType() { + return mEquipmentType; + } + + public Integer getId() { + return mId; + } + + public Boolean getNoted() { + return mNoted; + } + + public String getTimezone() { + return mTimezone; + } + + public String getWorkoutCategory() { + return mWorkoutCategory; + } + + public String getWorkoutSource() { + return mWorkoutSource; + } + + /** + * Minimal constructor for use in getting details + * + * @param intervalId int id of interval to be used + */ + public Interval(int intervalId) { + mId = intervalId; + } + + /** + * Constructs an Interval with the response from any kind of workout list request + * + * @param interval {@link JSONObject} response from any interval request + */ + public Interval(JSONObject interval) { + mAvgHeartRate = interval.optDouble("avgHeartRate"); + mAvgResistance = interval.optDouble("avgResistance"); + mAvgSpeed = interval.optDouble("avgSpeed"); + mPointsLabel = interval.optString("pointsLabel"); + mStartDateLocal = interval.optString("startDateLocal"); + mStartDateUtc = interval.optDouble("startDateUtc"); + mTotalCalories = interval.optInt("totalCalories"); + mTotalDistance = interval.optDouble("totalDistance"); + mTotalDuration = interval.optInt("totalDuration"); + mTotalPoints = interval.optInt("totalPoints"); + mTotalWorkout = interval.optInt("totalWorkout"); + + mDeviceType = interval.optString("deviceType"); + mEquipmentType = interval.optString("equipmentType"); + mId = interval.optInt("id"); + mNoted = interval.optBoolean("noted"); + mTimezone = interval.optString("timezone"); + mWorkoutCategory = interval.optString("workoutCategory"); + mWorkoutSource = interval.optString("workoutSource"); + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Location.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Location.java new file mode 100644 index 0000000..4b145c8 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Location.java @@ -0,0 +1,188 @@ +package com.iamthefij.otbeta.api; + +import org.json.JSONObject; + +import java.util.List; +import java.util.Locale; + +/** + * Represents a company location + */ +public class Location { + // Always present + private final Integer mId; + private final String mExternalMappingId; + private final String mFeatures; + private final String mMms; + private final String mName; + private final String mPhone; + private final String mStatus; + private final String mStatusTillDate; + private final String mUuid; + private String mTimezone; + // Details only + private Address mAddress; + + public Integer getId() { + return mId; + } + + public String getUuid() { + return mUuid; + } + + public String getName() { + return mName; + } + + public String getExternalMappingId() { + return mExternalMappingId; + } + + public String getTimezone() { + return mTimezone; + } + + public String getPhone() { + return mPhone; + } + + public String getFeatures() { + return mFeatures; + } + + public String getStatus() { + return mStatus; + } + + public String getStatusTillDate() { + return mStatusTillDate; + } + + public String getMms() { + return mMms; + } + + public Address getAddress() { + return mAddress; + } + + public boolean hasAddress() { + return mAddress != null; + } + + /** + * Creates a Location from a {@link JSONObject} + */ + public Location(JSONObject locationJSON) { + mId = locationJSON.optInt("id"); + mUuid = locationJSON.optString("uuid"); + mName = locationJSON.optString("name"); + mExternalMappingId = locationJSON.optString("externalMappingId"); + mTimezone = locationJSON.optString("timezone"); + mPhone = locationJSON.optString("phone"); + mFeatures = locationJSON.optString("features"); + mStatus = locationJSON.optString("status"); + mStatusTillDate = locationJSON.optString("statusTillDate"); + mMms = locationJSON.optString("mms"); + + JSONObject address = locationJSON.optJSONObject("address"); + if (address != null) { + mAddress = new Address(); + mAddress.setAddressLine1(address.optString("addressLine1")); + mAddress.setAddressLine2(address.optString("addressLine2")); + mAddress.setLocality(address.optString("city")); + mAddress.setCountryCode(address.optString("country")); + mAddress.setPostalCode(address.optString("postalCode")); + mAddress.setAdminArea(address.optString("stateOrProvince")); + if (mTimezone == null) { + mTimezone = address.optString("timezone"); + } + mAddress.setLatitude(address.optDouble("lat")); + mAddress.setLongitude(address.optDouble("lng")); + } + } + + public class Address { + String mAddressLine1; + String mAddressLine2; + String mLocality; + String mCountryCode; + String mPostalCode; + String mAdminArea; + Double mLatitude; + Double mLongitude; + + public String getAddressLine1() { + return mAddressLine1; + } + + public void setAddressLine1(String addressLine) { + mAddressLine1 = addressLine; + } + + public String getAddressLine2() { + return mAddressLine2; + } + + public void setAddressLine2(String addressLine) { + mAddressLine2 = addressLine; + } + + public String getLocality() { + return mLocality; + } + + public void setLocality(String locality) { + mLocality = locality; + } + + public String getCountryCode() { + return mCountryCode; + } + + public void setCountryCode(String countryCode) { + mCountryCode = countryCode; + } + + public String getPostalCode() { + return mPostalCode; + } + + public void setPostalCode(String postalCode) { + mPostalCode = postalCode; + } + + public String getAdminArea() { + return mAdminArea; + } + + public void setAdminArea(String adminArea) { + mAdminArea = adminArea; + } + + public Double getLatitude() { + return mLatitude; + } + + public boolean hasLatitude() { + return mLatitude != null && !mLatitude.isNaN(); + } + + public void setLatitude(Double latitude) { + mLatitude = latitude; + } + + public Double getLongitude() { + return mLongitude; + } + + public boolean hasLongitude() { + return mLongitude != null && !mLongitude.isNaN(); + } + + public void setLongitude(Double longitude) { + mLongitude = longitude; + } + } +} diff --git a/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Workout.java b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Workout.java new file mode 100644 index 0000000..e5e5302 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/java/com/iamthefij/otbeta/api/Workout.java @@ -0,0 +1,234 @@ +package com.iamthefij.otbeta.api; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +/** + * A single workout with all metrics and details + */ + +public class Workout { + private HeartRateZones mHrm; + private final Integer mCalories; + private final Integer mDuration; + private final Integer mId; + private final Integer mPoints; + private final Integer mStartTimeUtc; + private Metric mDistance; + private Metric mHeartRate; + private Metric mResistance; + private Metric mSpeed; + private Metric mVertical; + private final String mDeviceType; + private final String mNotes; + private final String mPointsLabel; + private final String mSource; + private final String mSourceLogo; + private final String mStartTimeLocal; + private final String mTimezone; + + public HeartRateZones getHrm() { + return mHrm; + } + + public Integer getCalories() { + return mCalories; + } + + public Integer getDuration() { + return mDuration; + } + + public Integer getId() { + return mId; + } + + public Integer getPoints() { + return mPoints; + } + + public Integer getStartTimeUtc() { + return mStartTimeUtc; + } + + public Metric getDistance() { + return mDistance; + } + + public Metric getHeartRate() { + return mHeartRate; + } + + public Metric getResistance() { + return mResistance; + } + + public Metric getSpeed() { + return mSpeed; + } + + public Metric getVertical() { + return mVertical; + } + + public String getDeviceType() { + return mDeviceType; + } + + public String getNotes() { + return mNotes; + } + + public String getPointsLabel() { + return mPointsLabel; + } + + public String getSource() { + return mSource; + } + + public String getSourceLogo() { + return mSourceLogo; + } + + public String getStartTimeLocal() { + return mStartTimeLocal; + } + + public String getTimezone() { + return mTimezone; + } + + public Workout(JSONObject workout) { + mCalories = workout.optInt("calories"); + mDuration = workout.optInt("duration"); + mId = workout.optInt("id"); + mPoints = workout.optInt("points"); + mStartTimeUtc = workout.optInt("startTimeUtc"); + mDeviceType = workout.optString("deviceType"); + mNotes = workout.optString("notes"); + mPointsLabel = workout.optString("pointsLabel"); + mSource = workout.optString("source"); + mSourceLogo = workout.optString("sourceLogo"); + mStartTimeLocal = workout.optString("startTimeLocal"); + mTimezone = workout.optString("timezone"); + + JSONObject distance = workout.optJSONObject("distance"); + if (distance != null) { + mDistance = new Metric(distance); + } + + JSONObject heartRate = workout.optJSONObject("heartRate"); + if (heartRate != null) { + mHeartRate = new Metric(heartRate); + } + + JSONObject resistance = workout.optJSONObject("resistance"); + if (resistance != null) { + mResistance = new Metric(resistance); + } + + JSONObject speed = workout.optJSONObject("speed"); + if (speed != null) { + mSpeed = new Metric(speed); + } + + JSONObject vertical = workout.optJSONObject("vertical"); + if (vertical != null) { + mVertical = new Metric(vertical); + } + + JSONObject hrm = workout.optJSONObject("hrm"); + if (hrm != null) { + mHrm = new HeartRateZones(hrm); + } + } + + public class Metric { + private final Double mAverage; + private final Integer mInterval; + private final String mUnits; + private List mValues; + private final Double mValue; + + public Double getAverage() { + return mAverage; + } + + public Integer getInterval() { + return mInterval; + } + + public String getUnits() { + return mUnits; + } + + public List getValues() { + return mValues; + } + + public Double getValue() { + return mValue; + } + + public Metric(JSONObject metric) { + mAverage = metric.optDouble("average"); + mInterval = metric.optInt("interval"); + mUnits = metric.optString("units"); + mValue = metric.optDouble("value"); + + JSONArray values = metric.optJSONArray("values"); + if (values != null) { + mValues = new ArrayList<>(values.length()); + for (int i = 0; i < values.length(); i++) { + mValues.add(values.optDouble(i)); + } + } + } + } + + public class HeartRateZones { + private final Integer mBlueZoneSeconds; + private final Integer mGreenZoneSeconds; + private final Integer mGreyZoneSeconds; + private final Integer mOrangeZoneSeconds; + private final Integer mRedZoneSeconds; + private final Integer mTargetHeartRate; + + public Integer getBlueZoneSeconds() { + return mBlueZoneSeconds; + } + + public Integer getGreenZoneSeconds() { + return mGreenZoneSeconds; + } + + public Integer getGreyZoneSeconds() { + return mGreyZoneSeconds; + } + + public Integer getOrangeZoneSeconds() { + return mOrangeZoneSeconds; + } + + public Integer getRedZoneSeconds() { + return mRedZoneSeconds; + } + + public Integer getTargetHeartRate() { + return mTargetHeartRate; + } + + public HeartRateZones(JSONObject zones) { + mBlueZoneSeconds = zones.optInt("blueZoneSeconds"); + mGreenZoneSeconds = zones.optInt("greenZoneSeconds"); + mGreyZoneSeconds = zones.optInt("greyZoneSeconds"); + mOrangeZoneSeconds = zones.optInt("orangeZoneSeconds"); + mRedZoneSeconds = zones.optInt("redZoneSeconds"); + mTargetHeartRate = zones.optInt("targetHeartRate"); + } + } +} diff --git a/com.iamthefij.otbeta/src/main/res/layout-w900dp/workout_list.xml b/com.iamthefij.otbeta/src/main/res/layout-w900dp/workout_list.xml new file mode 100644 index 0000000..125b370 --- /dev/null +++ b/com.iamthefij.otbeta/src/main/res/layout-w900dp/workout_list.xml @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/com.iamthefij.otbeta/src/main/res/layout/activity_login.xml b/com.iamthefij.otbeta/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..7c7984d --- /dev/null +++ b/com.iamthefij.otbeta/src/main/res/layout/activity_login.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +