Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

deep linking first pass #171

Merged
merged 16 commits into from
Nov 20, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,22 @@

</activity>

<activity
android:name=".ui.activities.DeepLinkActivity">

<intent-filter
android:autoVerify="true"
tools:ignore="UnusedAttribute">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="www.kickstarter.com"
android:scheme="https"
android:pathPrefix="/projects" />
</intent-filter>
</activity>

<!-- SERVICES, PROVIDERS, RECEIVERS -->

<!-- Sets up the app to send, receive, register with GCM. -->
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/com/kickstarter/libs/Koala.java
Original file line number Diff line number Diff line change
Expand Up @@ -497,4 +497,11 @@ public void trackOpenedExternalLink(final @NonNull Project project, final @NonNu

this.client.track(KoalaEvent.OPENED_EXTERNAL_LINK, props);
}

// DEEP LINK
public void trackContinueUserActivityAndOpenedDeepLink() {
this.client.track(KoalaEvent.CONTINUE_USER_ACTIVITY);

this.client.track(KoalaEvent.OPENED_DEEP_LINK);
}
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/kickstarter/libs/KoalaEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ private KoalaEvent() {}
public static final String ACTIVITY_VIEW = "Activity View";
public static final String ACTIVITY_VIEW_ITEM = "Activity View Item";
public static final String CLEARED_SEARCH_TERM = "Cleared Search Term";
public static final String CONTINUE_USER_ACTIVITY = "Continue User Activity";
public static final String DISCOVER_SEARCH_LEGACY = "Discover Search";
public static final String DISCOVER_SEARCH_RESULTS_LEGACY = "Discover Search Results";
public static final String DISCOVER_SEARCH_RESULTS_LOAD_MORE_LEGACY = "Discover Search Results Load More";
public static final String LOADED_MORE_SEARCH_RESULTS = "Loaded More Search Results";
public static final String LOADED_OLDER_COMMENTS = "Loaded Older Comments";
public static final String LOADED_SEARCH_RESULTS = "Loaded Search Results";
public static final String NOTIFICATION_OPENED_LEGACY = "Notification Opened";
public static final String OPENED_DEEP_LINK = "Opened Deep Link";
public static final String OPENED_EXTERNAL_LINK = "Opened External Link";
public static final String OPENED_NOTIFICATION = "Opened Notification";
public static final String POSTED_COMMENT = "Posted Comment";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.kickstarter.ui.activities;

import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import com.kickstarter.libs.BaseActivity;
import com.kickstarter.libs.RefTag;
import com.kickstarter.libs.qualifiers.RequiresActivityViewModel;
import com.kickstarter.libs.utils.ApplicationUtils;
import com.kickstarter.ui.IntentKey;
import com.kickstarter.viewmodels.DeepLinkViewModel;

import java.util.List;

import static com.kickstarter.libs.rx.transformers.Transformers.observeForUI;

@RequiresActivityViewModel(DeepLinkViewModel.ViewModel.class)
public final class DeepLinkActivity extends BaseActivity<DeepLinkViewModel.ViewModel> {
@Override
protected void onCreate(final @Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

this.viewModel.outputs.requestPackageManager()
.compose(bindToLifecycle())
.compose(observeForUI())
.subscribe(__ -> inputPackageManager());

this.viewModel.outputs.startBrowser()
.compose(bindToLifecycle())
.compose(observeForUI())
.subscribe(this::startBrowser);

this.viewModel.outputs.startDiscoveryActivity()
.compose(bindToLifecycle())
.compose(observeForUI())
.subscribe(__ -> startDiscoveryActivity());

this.viewModel.outputs.startProjectActivity()
.compose(bindToLifecycle())
.compose(observeForUI())
.subscribe(this::startProjectActivity);
}

private void startDiscoveryActivity() {
ApplicationUtils.startNewDiscoveryActivity(this);
finish();
}

private void startProjectActivity(final @NonNull Uri uri) {
final Intent projectIntent = new Intent(this, ProjectActivity.class)
.setData(uri);
final String ref = uri.getQueryParameter("ref");
if (ref != null) {
projectIntent.putExtra(IntentKey.REF_TAG, RefTag.from(ref));
}
startActivity(projectIntent);
finish();
}

private void startBrowser(final @NonNull List<Intent> targetIntents) {
if (!targetIntents.isEmpty()) {
Copy link
Contributor

@swoopej swoopej Nov 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to confirm we want to call this method even if the targetIntents are an empty list? If we don't need to call this then we could add a filter on the end of the output and have it not emit (but maybe we want finish() to get called here?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we want to finish the DeepLinkActivity!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noice

/* We need to remove the first intent so it's not duplicated
when we add the EXTRA_INITIAL_INTENTS intents. */
final Intent chooserIntent = Intent.createChooser(targetIntents.remove(0), "");
chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS,
targetIntents.toArray(new Parcelable[targetIntents.size()]));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could any of this logic get pushed into the ViewModel? not sure if it relies on any context from the activity...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think only startActivity, I really just need the chooserIntent.

startActivity(chooserIntent);
}
finish();
}

private void inputPackageManager() {
this.viewModel.inputs.packageManager(getPackageManager());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import rx.Observable;

public final class ProjectIntentMapper {
public static final String SCHEME_KSR = "ksr";
public static final String SCHEME_HTTPS = "https";
private ProjectIntentMapper() {}

// /projects/param-1/param-2*
Expand Down Expand Up @@ -90,7 +92,8 @@ private ProjectIntentMapper() {}
return null;
}

if (!uri.getScheme().equals("ksr")) {
final String scheme = uri.getScheme();
if (!(scheme.equals(SCHEME_KSR) || scheme.equals(SCHEME_HTTPS))) {
return null;
}

Expand Down
139 changes: 139 additions & 0 deletions app/src/main/java/com/kickstarter/viewmodels/DeepLinkViewModel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.kickstarter.viewmodels;

import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import android.util.Pair;

import com.kickstarter.libs.ActivityViewModel;
import com.kickstarter.libs.Environment;
import com.kickstarter.services.KSUri;
import com.kickstarter.ui.activities.DeepLinkActivity;

import java.util.List;

import rx.Observable;
import rx.subjects.BehaviorSubject;
import rx.subjects.PublishSubject;

import static com.kickstarter.libs.rx.transformers.Transformers.ignoreValues;

public interface DeepLinkViewModel {

interface Inputs {
/**
* Call when user clicks link that can't be deep linked.
*/
void packageManager(PackageManager packageManager);
}

interface Outputs {
/**
* Emits when we need to get {@link PackageManager} to query for activities that can open a link.
*/
Observable<Void> requestPackageManager();

/**
* Emits when we should start an external browser because we don't want to deep link.
*/
Observable<List<Intent>> startBrowser();

/**
* Emits when we should start the {@link com.kickstarter.ui.activities.DiscoveryActivity}.
*/
Observable<Void> startDiscoveryActivity();

/**
* Emits when we should start the {@link com.kickstarter.ui.activities.ProjectActivity}.
*/
Observable<Uri> startProjectActivity();
}

final class ViewModel extends ActivityViewModel<DeepLinkActivity> implements Outputs, Inputs {
public ViewModel(final @NonNull Environment environment) {
super(environment);

final Observable<Uri> uriFromIntent = intent()
.map(Intent::getData)
.ofType(Uri.class);

uriFromIntent
.filter(uri -> uri.getLastPathSegment().equals("projects"))
.compose(ignoreValues())
.compose(bindToLifecycle())
.subscribe(this.startDiscoveryActivity::onNext);

this.startDiscoveryActivity
.subscribe(__ -> koala.trackContinueUserActivityAndOpenedDeepLink());

uriFromIntent
.filter(uri -> KSUri.isProjectUri(uri, uri.toString()))
.compose(bindToLifecycle())
.subscribe(this.startProjectActivity::onNext);

this.startProjectActivity
.subscribe(__ -> koala.trackContinueUserActivityAndOpenedDeepLink());

final Observable<Pair<PackageManager, Uri>> packageManagerAndUri =
Observable.combineLatest(this.packageManager, uriFromIntent, Pair::create);

final Observable<List<Intent>> targetIntents = packageManagerAndUri
.flatMap(pair -> {
/* We use a fake Uri because in Android 6.0 and above,
if a link is domain verified, only that app is returned. */
final Uri fakeUri = Uri.parse("http://www.kickstarter.com");
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, fakeUri);
return Observable.from(pair.first.queryIntentActivities(browserIntent, 0))
.filter(resolveInfo -> !resolveInfo.activityInfo.packageName.contains("com.kickstarter"))
.map(resolveInfo -> {
final Intent intent = new Intent(Intent.ACTION_VIEW, pair.second);
intent.setPackage(resolveInfo.activityInfo.packageName);
intent.setData(pair.second);
return intent;
})
.toList();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

niceee

});

targetIntents
.compose(bindToLifecycle())
.subscribe(this.startBrowser::onNext);

uriFromIntent
.filter(uri -> !uri.getLastPathSegment().equals("projects") && !KSUri.isProjectUri(uri, uri.toString()))
.map(Uri::toString)
.filter(url -> !TextUtils.isEmpty(url))
.compose(ignoreValues())
.compose(bindToLifecycle())
.subscribe(this.requestPackageManager::onNext);
}

private final PublishSubject<PackageManager> packageManager = PublishSubject.create();

private final BehaviorSubject<Void> requestPackageManager = BehaviorSubject.create();
private final BehaviorSubject<List<Intent>> startBrowser = BehaviorSubject.create();
private final BehaviorSubject<Void> startDiscoveryActivity = BehaviorSubject.create();
private final BehaviorSubject<Uri> startProjectActivity = BehaviorSubject.create();

public final Inputs inputs = this;
public final Outputs outputs = this;

@Override public void packageManager(final PackageManager packageManager) {
this.packageManager.onNext(packageManager);
}

@Override public @NonNull Observable<Void> requestPackageManager() {
return this.requestPackageManager;
}
@Override public @NonNull Observable<List<Intent>> startBrowser() {
return this.startBrowser;
}
@Override public @NonNull Observable<Void> startDiscoveryActivity() {
return this.startDiscoveryActivity;
}
@Override public @NonNull Observable<Uri> startProjectActivity() {
return this.startProjectActivity;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@ public void testProject_emitsFromKsrProjectUri() {
}

@Test
public void testProject_doesNotEmitFromHttpsProjectUri() {
public void testProject_emitsFromHttpsProjectUri() {
final Uri uri = Uri.parse("https://www.kickstarter.com/projects/1186238668/skull-graphic-tee");
final Intent intent = new Intent(Intent.ACTION_VIEW, uri);

final TestSubscriber<Project> resultTest = TestSubscriber.create();
ProjectIntentMapper.project(intent, new MockApiClient())
.subscribe(resultTest);

resultTest.assertNoValues();
resultTest.assertValueCount(1);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.kickstarter.viewmodels;

import android.content.Intent;
import android.net.Uri;

import com.kickstarter.KSRobolectricTestCase;
import com.kickstarter.libs.KoalaEvent;

import org.junit.Test;

import java.util.List;

import rx.observers.TestSubscriber;

public class DeepLinkViewModelTest extends KSRobolectricTestCase {
private DeepLinkViewModel.ViewModel vm;
private final TestSubscriber<Void> requestPackageManager = new TestSubscriber<>();
private final TestSubscriber<List<Intent>> startBrowser = new TestSubscriber<>();
private final TestSubscriber<Void> startDiscoveryActivity = new TestSubscriber<>();
private final TestSubscriber<Uri> startProjectActivity = new TestSubscriber<>();

protected void setUpEnvironment() {
this.vm = new DeepLinkViewModel.ViewModel(environment());
this.vm.outputs.requestPackageManager().subscribe(this.requestPackageManager);
this.vm.outputs.startBrowser().subscribe(this.startBrowser);
this.vm.outputs.startDiscoveryActivity().subscribe(this.startDiscoveryActivity);
this.vm.outputs.startProjectActivity().subscribe(this.startProjectActivity);
}

@Test
public void testNonDeepLink_startsBrowser() {
setUpEnvironment();

final String url = "https://www.kickstarter.com/projects/smithsonian/smithsonian-anthology-of-hip-hop-and-rap/comments";
this.vm.intent(intentWithData(url));
this.vm.packageManager(application().getPackageManager());

this.requestPackageManager.assertValueCount(1);
this.startBrowser.assertValueCount(1);
this.startDiscoveryActivity.assertNoValues();
this.startProjectActivity.assertNoValues();
this.koalaTest.assertNoValues();
}

@Test
public void testProjectDeepLink_startsProjectActivity() {
setUpEnvironment();

final String url = "https://www.kickstarter.com/projects/smithsonian/smithsonian-anthology-of-hip-hop-and-rap";
this.vm.intent(intentWithData(url));

this.startProjectActivity.assertValue(Uri.parse(url));
this.startBrowser.assertNoValues();
this.requestPackageManager.assertNoValues();
this.startDiscoveryActivity.assertNoValues();
this.koalaTest.assertValues(KoalaEvent.CONTINUE_USER_ACTIVITY, KoalaEvent.OPENED_DEEP_LINK);
}

@Test
public void testDiscoveryDeepLink_startsDiscoveryActivity() {
setUpEnvironment();

final String url = "https://www.kickstarter.com/projects";
this.vm.intent(intentWithData(url));

this.startDiscoveryActivity.assertValueCount(1);
this.startBrowser.assertNoValues();
this.requestPackageManager.assertNoValues();
this.startProjectActivity.assertNoValues();
this.koalaTest.assertValues(KoalaEvent.CONTINUE_USER_ACTIVITY, KoalaEvent.OPENED_DEEP_LINK);
}

private Intent intentWithData(final String url) {
return new Intent()
.setData(Uri.parse(url));
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

siqqqqq tests!