Skip to content

Commit

Permalink
Add Dagger and architecture classes (getodk#1678)
Browse files Browse the repository at this point in the history
Here's a look at the new dependencies and some reasoning behind them:

Android Architecture Components
implementation "android.arch.lifecycle:extensions:1.0.0"
We should be moving to a MV* architecture for future Activity/Fragment/View development. The current popular options for this are MVP, and MVVM. With either of them, we have to do one of two things:

Build our own internal MV* system so that the management of the P/VM component is standard across the project.
Use some sort of external MVP/MVVM framework, like Mosby, or Architecture Components.
I figured using Android's Architecture Components was the best choice here: its a "standard" implementation, and its unlikely to go anywhere. It also allows us to persist the VM across Activity config and state changes.

Dagger
implementation 'com.google.dagger:dagger-android:2.11'
implementation 'com.google.dagger:dagger-android-support:2.11'
annotationProcessor 'com.google.dagger:dagger-android-processor:2.11'
annotationProcessor 'com.google.dagger:dagger-compiler:2.11'
The biggest pain point with the Geo refactor is that we currently have 6 separate Activities, even though technically the majority of functionality is shared, and the only differences are how the data is stored (point, shape, trace) and presented (Google, OSM).

We could go the route of creating a class hierarchy here that contains the shared functionality and then overrides them for each subtype, (perhaps Geo -> GeoPoint -> GeoPointGoogle, or Geo -> GeoGoogle -> GeoGooglePoint), but this means a rather arbitrary hierarchy and a lot of overriding.

Ideally, we solve this problem with composition instead of inheritance. We really only need one Activity, with the components that change chosen based on what combination of Point/Shape/Trace and Google/OSM we have. That way instead of having six Activities with unclear division of responsibilities, we have one with clear, discrete modules that can be mocked for testing purposes.

While this approach is preferred, it quickly becomes difficult without DI, as our dependency tree gets quite complex very quickly. Sometimes a deeply embedded dependency will need access to something further up the tree—like our LocationClient needing access to a Context for instance—and the only way forward without DI is to either:

A) Pass the context down through every dependency layer (i.e. via the Constructor), which quickly becomes impractical.

B) Reach out via a static method to pluck a Context from the void like we do with Collect.getInstance. This results in embedded dependencies and makes it more difficult to mock and test.

The biggest cost to Dagger is getting the boilerplate setup, and understanding it. I've put things together in a way that there really are only three classes that should ever be modified:

AppModule
Here we can add @provides methods if we want to inject dependencies into the Collect class itself. These instances will be rare, but could still be useful.

For instance we could change:

    // share all session cookies across all sessions...
    private CookieStore cookieStore = new BasicCookieStore();
    // retain credentials for 7 minutes...
    private CredentialsProvider credsProvider = new AgingCredentialsProvider(7 * 60 * 1000);
To:

    @Inject
    private CookieStore cookieStore;

    @Inject
    private CredentialsProvider credentialsProvider;
And then add to AppModule:

@PerApplication   // (More on this in a second).
@provides
public CookieStore provideCookieStore() {
    return new BasicCookieStore();
}

@PerApplication
@provides
public CredentialsProvider provideCredentialsProvider() {
    return new AgingCredentialsProvider(7 * 60 * 1000);
}
Which would allow us in a test setting to provide different values, or mocks, etc.

ActivityBuilder
Here we can add new injectable Activities, just by adding a new bind method, i.e.:

@PerActivity
abstract OurNewActivity bindNewActivity();
We can then either have OurNewActivity extend InjectableActivity, or simply call AndroidInjection.inject(this) in the Activity's onCreate.

Since Dagger injects at compile-time, it needs to know which specific classes will be injected. If we don't provide specific bind methods here, we have to manually call inject at the furthest subclassed level in order for it to see all of the @inject-able fields.

ViewModelBuilder
Here we can add new injectable ViewModels. Just like ActivityBuilder, we just need to copy the bind method like so:

@BINDS
@Intomap
@ViewModelKey(OurNewViewModel.class)
@PerViewModel
abstract ViewModel bindNewViewModel(OurNewViewModel newViewModel);
Because the VMs are retrieved from the ViewModelProviders class, there's a bit more boilerplate involved in retrieving one already injected. If you can, overriding the MVVMActivity/MVVMViewModel classes will handle this boilerplate for you.

If you can't, you just need to add an @Inject ViewModelProvider.Factory factory; field to your Activity, and call ViewModelProviders.of(this, factory) in your Activity to retrieve the injected VM.

Technically, these are the only three classes that should ever be modified moving forward. Everything else was one time set up that should be good to go until a new Dagger release comes out with a feature we want to make use of.

Here's some info on the remainder of the Dagger classes:

Scopes

Any dependency or @provides method annotated with a scope will act as a singleton within that scope. Dagger keeps track of these scopes and doesn't let an app provide/inject dependencies at a scope level above our current one. Injected objects without a Scope will be newly created every time they are injected.
Right now we have three scopes:
PerApplication
Lives the entire time the app does. Our "parent" scope.
PerActivity
Lives the entire time an Activity does. Good for dependencies that depend on an Activity or Activity Context.
PerViewModel
Lives the entire time a ViewModel does. Keep Activity Context dependencies out of these as the VM may outlive the Activity.
These may sound a bit confusing, but they're basically just helpful annotations to create Singletons that only live at a single level of the application. Basically if its something that's going to live in your VM, add @perviewmodel, and then any time that class is Injected, the single shared instance will be used.
If you don't add a scope, there's no issue! It just means you won't be able to share that specific instance across multiple dependencies.
We can always add more Scopes as we need them. Some apps use them to define things that should live as long as a User is logged in, or a Session is open, etc. For now though, we're probably good with these three.
AppComponent

Acts as the very top node of our dependency graph. Every dependency managed by dagger is a part of this Component.
Binds our Collect instance to the "Application" in the graph, and then injects any App level dependencies (any fields in Collect marked @Inject).
Shouldn't be modified at this point, pure boilerplate.
ActivityViewModelProvider, ViewModelFactory, ViewModelFactoryModule, ViewModelKey

These classes handle the magic behind making sure the VMs we add to ViewModelBuilder are properly constructed and injected.
They really shouldn't be modified at this point.
RxJava
implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
implementation 'io.reactivex.rxjava2:rxjava:2.1.6'
Rx allows us to define the boundaries between our dependencies as unidirectional, composable flows of data. I don't have a great way to describe what this looks like right now without examples, but when the refactor is done I'll comment again with some.

Basically without Rx on the Geo Activities, we end with the state-based, callback heavy spaghetti that we have now. Its very hard to know what effects what, as the data is moving in multiple directions.

RxRelay
implementation 'com.jakewharton.rxrelay2:rxrelay:2.0.0'
This just adds a slight improvement on Rx "subjects". We can remove it but I'd prefer not to. Simple library.

RxLifecycle
implementation 'com.trello.rxlifecycle2:rxlifecycle:2.2.1'
implementation 'com.trello.rxlifecycle2:rxlifecycle-android:2.2.1'
implementation 'com.trello.rxlifecycle2:rxlifecycle-android-lifecycle:2.2.1'
When dealing with Rx, we have to manage which data flows we're observing, otherwise we risk memory leaks. RxLifecycle allows us to make a simple .compose(bindToLifecycle()) call anytime we're observing something within an Activity or ViewModel and the binding will be cleaned up for us.

ButterKnife
implementation 'com.jakewharton:butterknife:8.8.1'
annotationProcessor 'com.jakewharton:butterknife-compiler:8.8.1'
Previously I thought we would go with the built-in DataBinding library, but it doesn't look like its being maintained much anymore. Further, whenever there's an error in the binding code, it breaks the entire build and spits out an enormous list of errors. Combing through these to find the actual error is extremely time consuming and demoralizing.

Butterknife is a super simple way to achieve the same thing with almost no overhead. We just annotate fields and methods with @BindView(R.id.our_view_id) View view; or @OnClick(R.id.our_View_id) public void onClick() {}, call ButterKnife.bind(this) (this can be handled from a base class) and we don't have to deal with findViewById() calls anymore.
  • Loading branch information
heyjamesknight authored and lognaturel committed Dec 28, 2017
1 parent 44699ea commit 9b7cabc
Show file tree
Hide file tree
Showing 51 changed files with 884 additions and 70 deletions.
31 changes: 29 additions & 2 deletions collect_app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ android {
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_7
targetCompatibility JavaVersion.VERSION_1_7
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

dexOptions {
Expand Down Expand Up @@ -199,6 +199,7 @@ dependencies {
implementation(group: 'org.opendatakit', name: 'opendatakit-javarosa', version: '2.7.0') {
exclude module: 'joda-time'
}

implementation group: 'org.osmdroid', name: 'osmdroid-android', version: '5.6.4'
implementation group: 'org.slf4j', name: 'slf4j-android', version: '1.6.1-RC1'
implementation group: 'pub.devrel', name: 'easypermissions', version: '0.2.1'
Expand All @@ -224,8 +225,34 @@ dependencies {
androidTestImplementation group: 'com.squareup.leakcanary', name: 'leakcanary-android-no-op', version: '1.5.4'
odkCollectReleaseImplementation group: 'com.squareup.leakcanary', name: 'leakcanary-android-no-op', version: '1.5.4'

// Android Architecture Components:
implementation "android.arch.lifecycle:extensions:1.0.0"

// Dagger:
implementation 'com.google.dagger:dagger-android:2.11'
implementation 'com.google.dagger:dagger-android-support:2.11'
annotationProcessor 'com.google.dagger:dagger-android-processor:2.11'
annotationProcessor 'com.google.dagger:dagger-compiler:2.11'

// RxJava 2:
implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
implementation 'io.reactivex.rxjava2:rxjava:2.1.6'

// Better "Subjects" for Rx:
implementation 'com.jakewharton.rxrelay2:rxrelay:2.0.0'

// Android bindings for Rx:
implementation 'com.jakewharton.rxbinding2:rxbinding:2.0.0'

// RxLifecycle (binds subscription cleanup to component lifecycle):
implementation 'com.trello.rxlifecycle2:rxlifecycle:2.2.1'
implementation 'com.trello.rxlifecycle2:rxlifecycle-android:2.2.1'
implementation 'com.trello.rxlifecycle2:rxlifecycle-android-lifecycle:2.2.1'

// Makes binding to Views easy:
implementation 'com.jakewharton:butterknife:8.8.1'
annotationProcessor 'com.jakewharton:butterknife-compiler:8.8.1'

// Testing-only dependencies
testImplementation group: 'junit', name: 'junit', version: '4.12'
testImplementation group: 'org.mockito', name: 'mockito-core', version: '2.12.0'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.odk.collect.android.location;

import android.support.test.rule.ActivityTestRule;
import android.support.test.runner.AndroidJUnit4;

import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import static junit.framework.Assert.assertTrue;

@RunWith(AndroidJUnit4.class)
public class GeoActivityTest {

@Rule
ActivityTestRule<GeoActivity> activityTestRule;

@Test
public void shouldDoSomething() {
assertTrue(true);
}
}
21 changes: 11 additions & 10 deletions collect_app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ the specific language governing permissions and limitations under the License.
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />

<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
Expand Down Expand Up @@ -52,11 +51,10 @@ the specific language governing permissions and limitations under the License.
<uses-feature
android:name="android.hardware.screen.portrait"
android:required="false" />

<uses-feature
android:glEsVersion="0x00020000"
android:required="true" />
<!-- for Maps v2 functionality, want:
<!-- for Maps v2 functionality, want:
<uses-feature android:glEsVersion="0x00020000" android:required="false"/>
uses-feature android:glEsVersion="0x00020000" android:required="false"
BUT, the gl setting is not modified by the required parameter, so
Expand All @@ -79,20 +77,19 @@ the specific language governing permissions and limitations under the License.
android:xlargeScreens="true" />

<application
android:name="org.odk.collect.android.application.Collect"
android:name=".application.Collect"
android:icon="@drawable/notes"
android:installLocation="auto"
android:label="@string/app_name"
android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/AppThemeBase.Collect">

<provider
android:name="org.odk.collect.android.provider.FormsProvider"
android:name=".provider.FormsProvider"
android:authorities="org.odk.collect.android.provider.odk.forms"
android:exported="true" />
<provider
android:name="org.odk.collect.android.provider.InstanceProvider"
android:name=".provider.InstanceProvider"
android:authorities="org.odk.collect.android.provider.odk.instances"
android:exported="true" />

Expand Down Expand Up @@ -230,7 +227,6 @@ the specific language governing permissions and limitations under the License.
android:name=".activities.GeoTraceGoogleMapActivity"
android:configChanges="orientation"
android:label="@string/app_name" />

<activity
android:name=".activities.BearingActivity"
android:label="@string/app_name" />
Expand Down Expand Up @@ -278,6 +274,7 @@ the specific language governing permissions and limitations under the License.
<action android:name="com.google.android.gms.analytics.ANALYTICS_DISPATCH" />
</intent-filter>
</receiver>

<service
android:name="com.google.android.gms.analytics.AnalyticsService"
android:enabled="true"
Expand All @@ -287,7 +284,6 @@ the specific language governing permissions and limitations under the License.
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyBS-JQ-dnaZ_8qsbvSyr_I3rTPFd5fJsYI" />

<meta-data
android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version"
Expand All @@ -301,6 +297,11 @@ the specific language governing permissions and limitations under the License.
android:name="com.google.android.maps"
android:required="false" />

<activity
android:name=".location.GeoActivity"
android:configChanges="orientation"
android:exported="true"
android:label="@string/app_name" />
</application>

</manifest>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
import org.odk.collect.android.external.ExternalDataManager;
import org.odk.collect.android.fragments.dialogs.CustomDatePickerDialog;
import org.odk.collect.android.fragments.dialogs.NumberPickerDialog;
import org.odk.collect.android.injection.DependencyProvider;
import org.odk.collect.android.utilities.DependencyProvider;
import org.odk.collect.android.listeners.AdvanceToNextListener;
import org.odk.collect.android.listeners.FormLoaderListener;
import org.odk.collect.android.listeners.FormSavedListener;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@

import org.odk.collect.android.R;
import org.odk.collect.android.application.Collect;
import org.odk.collect.android.location.LocationClient;
import org.odk.collect.android.location.LocationClients;
import org.odk.collect.android.location.client.LocationClient;
import org.odk.collect.android.location.client.LocationClients;
import org.odk.collect.android.utilities.ToastUtils;
import org.odk.collect.android.widgets.GeoPointWidget;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@

import org.odk.collect.android.R;
import org.odk.collect.android.application.Collect;
import org.odk.collect.android.location.LocationClient;
import org.odk.collect.android.location.LocationClients;
import org.odk.collect.android.location.client.LocationClient;
import org.odk.collect.android.location.client.LocationClients;
import org.odk.collect.android.spatial.MapHelper;
import org.odk.collect.android.utilities.ToastUtils;
import org.odk.collect.android.widgets.GeoPointWidget;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@

import org.odk.collect.android.R;
import org.odk.collect.android.application.Collect;
import org.odk.collect.android.location.LocationClient;
import org.odk.collect.android.location.LocationClients;
import org.odk.collect.android.location.client.LocationClient;
import org.odk.collect.android.location.client.LocationClients;
import org.odk.collect.android.spatial.MapHelper;
import org.odk.collect.android.utilities.ToastUtils;
import org.odk.collect.android.widgets.GeoPointWidget;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@

import org.odk.collect.android.R;
import org.odk.collect.android.application.Collect;
import org.odk.collect.android.location.LocationClient;
import org.odk.collect.android.location.LocationClients;
import org.odk.collect.android.location.client.LocationClient;
import org.odk.collect.android.location.client.LocationClients;
import org.odk.collect.android.spatial.MapHelper;
import org.odk.collect.android.utilities.ToastUtils;
import org.odk.collect.android.widgets.GeoShapeWidget;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@

import org.odk.collect.android.R;
import org.odk.collect.android.application.Collect;
import org.odk.collect.android.location.LocationClient;
import org.odk.collect.android.location.LocationClients;
import org.odk.collect.android.location.client.LocationClient;
import org.odk.collect.android.location.client.LocationClients;
import org.odk.collect.android.spatial.MapHelper;
import org.odk.collect.android.utilities.ToastUtils;
import org.odk.collect.android.widgets.GeoTraceWidget;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
import com.google.android.gms.location.LocationListener;

import org.odk.collect.android.R;
import org.odk.collect.android.location.LocationClient;
import org.odk.collect.android.location.LocationClients;
import org.odk.collect.android.location.client.LocationClient;
import org.odk.collect.android.location.client.LocationClients;
import org.odk.collect.android.spatial.MapHelper;
import org.odk.collect.android.widgets.GeoTraceWidget;
import org.osmdroid.tileprovider.IRegisterReceiver;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.odk.collect.android.activities;

import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;

import dagger.android.AndroidInjection;

/**
* @author James Knight
*/

public abstract class InjectableActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
AndroidInjection.inject(this);
super.onCreate(savedInstanceState);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
import org.odk.collect.android.utilities.ApplicationConstants;
import org.odk.collect.android.utilities.AuthDialogUtility;
import org.odk.collect.android.utilities.PlayServicesUtil;
import org.odk.collect.android.utilities.ToastUtils;
import org.odk.collect.android.utilities.SharedPreferencesUtils;
import org.odk.collect.android.utilities.ToastUtils;

import java.io.File;
import java.io.FileInputStream;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

package org.odk.collect.android.application;

import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.content.res.Configuration;
Expand All @@ -40,26 +41,29 @@
import org.odk.collect.android.R;
import org.odk.collect.android.database.ActivityLogger;
import org.odk.collect.android.external.ExternalDataManager;
import org.odk.collect.android.injection.config.DaggerAppComponent;
import org.odk.collect.android.logic.FormController;
import org.odk.collect.android.logic.PropertyManager;
import org.odk.collect.android.preferences.AdminSharedPreferences;
import org.odk.collect.android.preferences.AutoSendPreferenceMigrator;
import org.odk.collect.android.preferences.FormMetadataMigrator;
import org.odk.collect.android.preferences.GeneralSharedPreferences;
import org.odk.collect.android.utilities.AgingCredentialsProvider;
import org.odk.collect.android.utilities.AuthDialogUtility;
import org.odk.collect.android.utilities.LocaleHelper;
import org.odk.collect.android.utilities.PRNGFixes;
import org.opendatakit.httpclientandroidlib.client.CookieStore;
import org.opendatakit.httpclientandroidlib.client.CredentialsProvider;
import org.opendatakit.httpclientandroidlib.client.protocol.HttpClientContext;
import org.opendatakit.httpclientandroidlib.impl.client.BasicCookieStore;
import org.opendatakit.httpclientandroidlib.protocol.BasicHttpContext;
import org.opendatakit.httpclientandroidlib.protocol.HttpContext;

import java.io.File;
import java.util.Locale;

import javax.inject.Inject;

import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasActivityInjector;
import timber.log.Timber;

import static org.odk.collect.android.logic.PropertyManager.PROPMGR_USERNAME;
Expand All @@ -73,7 +77,7 @@
*
* @author carlhartung
*/
public class Collect extends Application {
public class Collect extends Application implements HasActivityInjector {

// Storage paths
public static final String ODK_ROOT = Environment.getExternalStorageDirectory()
Expand All @@ -93,17 +97,21 @@ public class Collect extends Application {
public static String defaultSysLanguage;
private static Collect singleton = null;

// share all session cookies across all sessions...
private CookieStore cookieStore = new BasicCookieStore();
// retain credentials for 7 minutes...
private CredentialsProvider credsProvider = new AgingCredentialsProvider(7 * 60 * 1000);
@Inject
protected CookieStore cookieStore;
@Inject
protected CredentialsProvider credsProvider;

private ActivityLogger activityLogger;

@Nullable
private FormController formController = null;
private ExternalDataManager externalDataManager;
private Tracker tracker;

@Inject
DispatchingAndroidInjector<Activity> androidInjector;

public static Collect getInstance() {
return singleton;
}
Expand Down Expand Up @@ -249,6 +257,11 @@ public void onCreate() {
super.onCreate();
singleton = this;

DaggerAppComponent.builder()
.application(this)
.build()
.inject(this);

reloadSharedPreferences();

PRNGFixes.apply();
Expand Down Expand Up @@ -305,6 +318,7 @@ public synchronized Tracker getDefaultTracker() {
return tracker;
}


private static class CrashReportingTree extends Timber.Tree {
@Override
protected void log(int priority, String tag, String message, Throwable t) {
Expand Down Expand Up @@ -337,4 +351,9 @@ private void reloadSharedPreferences() {
GeneralSharedPreferences.getInstance().reloadPreferences();
AdminSharedPreferences.getInstance().reloadPreferences();
}

@Override
public DispatchingAndroidInjector<Activity> activityInjector() {
return androidInjector;
}
}
Loading

0 comments on commit 9b7cabc

Please sign in to comment.