Runtime Banner for Mobile Deep Linking (#13190)

* First version using custom schemes for iOS

* Starting to take shape with /r/mobile redirect page

* Wording and aasa

* Adds e2e tests

* Trigger CI

* Tweaks to AASA

* Uses external pivot domain to trigger Universal Links

* Add missing external domain deep link

* Fix test by enabling runtime_banner only in E2E tests

* Fix URL encoding mismatch in Cypress test

* banner sttyling

* Cleanup unrelated changes

* Add AASA tests + remove lingering Gemfile.lock changes

* Fix cypress test

* Remove unnecessary window global assignment

* Trigger Travis

* Replace querySelectorAll with querySelector

* Apply suggestions from code review

Co-authored-by: Vaidehi Joshi <vaidehi.sj@gmail.com>

* Refactor inline comments, extract URL.deep_link logic and other review feedback

* Small tweaks

* Remove untrusted user-provided redirect (CodeQL suggestion)

* Fix failing tests

* Whoops - another test fix

* Use Forem's UDL server

* Extract timeoutDelay and add comment clarifying

* Add target='_blank' to deep link

* Add TODO comment for replacing hardcoded identifiers

* Apply suggestions from code review

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>

* Add a11y attributes

Co-authored-by: Paweł Ludwiczak <ludwiczakpawel@gmail.com>
Co-authored-by: Vaidehi Joshi <vaidehi.sj@gmail.com>
Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
This commit is contained in:
Fernando Valverde 2021-04-23 15:45:34 -06:00 committed by GitHub
parent f233be805b
commit affc704c9b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 346 additions and 29 deletions

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="3" fill="#D55D5E"/>
<path d="M16.499 4.741H7.5a.501.501 0 0 0-.5.502V6.74c0 .277.225.501.501.501H16.5a.501.501 0 0 0 .5-.501V5.243a.501.501 0 0 0-.501-.502zm0 4.839H7.5a.501.501 0 0 0-.501.501v1.498c0 .276.225.501.501.501h9a.501.501 0 0 0 .501-.501V10.08a.501.501 0 0 0-.501-.501zM7.47 14a.47.47 0 0 0-.47.469v4.062a.47.47 0 0 0 .47.469h1.562a.468.468 0 0 0 .468-.469v-4.062A.47.47 0 0 0 9.032 14H7.47z" fill="#F6CBF7"/>
</svg>

After

Width:  |  Height:  |  Size: 565 B

View file

@ -4,7 +4,7 @@
initializeBaseTracking, initializeCommentsPage,
initializeArticleDate, initializeArticleReactions, initNotifications,
initializeCommentDate, initializeCommentDropdown, initializeSettings,
initializeCommentPreview,
initializeCommentPreview, initializeRuntimeBanner,
initializeTimeFixer, initializeDashboardSort, initializePWAFunctionality,
initializeEllipsisMenu, initializeArchivedPostFilter, initializeCreditsPage,
initializeUserProfilePage, initializeProfileInfoToggle, initializePodcastPlayback,
@ -17,6 +17,7 @@
function callInitializers() {
initializeBaseTracking();
initializeRuntimeBanner();
initializePaymentPointers();
initializeCommentsPage();
initializeArticleDate();

View file

@ -0,0 +1,85 @@
/* global Runtime */
/**
* Callback that dismisses the Runtime Banner
*/
function handleDismissRuntimeBanner() {
const runtimeBanner = document.querySelector('.runtime-banner');
if (runtimeBanner) {
runtimeBanner.remove();
}
}
/**
* This function redirects the browser to custom schemes, i.e. deep link into
* mobile apps. A separate function is needed in this case in order to test
* the feature using Cypress. More details can be found here:
* https://medium.com/cypress-io-thailand/understand-stub-spy-apis-that-will-make-your-test-better-than-ever-on-cypress-io-797cb9eb205a#b6ad
*
* @param {string} targetURI - The target custom scheme URI
*/
function launchCustomSchemeDeepLink(targetURI) {
window.location.href = targetURI;
}
/**
* Function that run on every page load (including InstantClick redirects) and
* is in charge of the setup that runs the following features:
* - addEventListener to dismiss the banner when users tap on X button
* - When the user lands in "/r/mobile"
* - Timeout that presents the fallback page if couldn't seamless deep link
* - Setup the correct fallback button links based on platform (iOS/Android)
*/
function initializeRuntimeBanner() {
// This will provide the dismiss functionality for the Runtime Banner
const bannerDismiss = document.querySelector('.runtime-banner__dismiss');
if (bannerDismiss) {
bannerDismiss.addEventListener('click', handleDismissRuntimeBanner);
}
// If the "Install now"/"Try again" buttons exist in the DOM it means we are
// trying to deep link into the mobile app after being redirected by the
// Runtime Banner itself (the browser is currently in `/r/mobile`)
const installNowButton = document.getElementById('link-to-mobile-install');
const retryButton = document.getElementById('link-to-mobile-install-retry');
if (!installNowButton || !retryButton) {
return;
}
// Since we were redirected to `/r/mobile` by the Banner itself we can safely
// dismiss it by re-using the handleDismissRuntimeBanner function
handleDismissRuntimeBanner();
// The target path comes in via GET request query params
const urlParams = new URLSearchParams(window.location.search);
const targetPath = urlParams.get('deep_link');
// Constants - they will become dynamic (configurable by creators) in upcoming releases
const FOREM_IOS_SCHEME = 'com.forem.app';
const FOREM_APP_STORE_URL =
'https://apps.apple.com/us/app/dev-community/id1439094790';
const FOREM_GOOGLE_PLAY_URL =
'https://play.google.com/store/apps/details?id=to.dev.dev_android';
if (Runtime.currentOS() === 'iOS') {
// The install now must target Apple's AppStore
installNowButton.href = FOREM_APP_STORE_URL;
// We try to deep link directly by launching a custom scheme and populate
// the retry button in case the user will need it
const targetLink = `${FOREM_IOS_SCHEME}://${window.location.host}${targetPath}`;
retryButton.href = targetLink;
launchCustomSchemeDeepLink(targetLink);
} else if (Runtime.currentOS() === 'Android') {
const targetIntent =
'intent://scan/#Intent;scheme=zxing;package=com.google.zxing.client.android;end';
retryButton.href = targetIntent;
installNowButton.href = FOREM_GOOGLE_PLAY_URL;
// Android support will not be available yet. Links to `/r/mobile` won't be
// visible in Android browsers. However, as a fallback (safety) measure to
// avoid having Android users land in this page we redirect them back to the
// home page. This to avoids users landing in an unsupported (not working
// for them) page.
window.location.href = '/';
}
}

View file

@ -1,15 +1,40 @@
.runtime-banner {
position: fixed;
left: 0;
right: 0;
bottom: calc(65px + env(safe-area-inset-bottom));
z-index: var(--z-sticky);
@import 'variables';
@import '_mixins';
&__inner {
background-color: #0b0c0c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-around;
.runtime-banner {
display: flex;
margin: 0 auto;
left: var(--su-4);
right: var(--su-4);
position: fixed;
bottom: calc(var(--su-9) + env(safe-area-inset-bottom));
flex-flow: row nowrap;
align-items: center;
padding: var(--su-1);
max-width: calc(100% - var(--su-4) * 2);
z-index: var(--z-popover);
border-radius: var(--radius);
background: var(--snackbar-bg);
color: var(--snackbar-color);
font-size: var(--fs-s);
:any-link {
color: inherit;
}
}
.mobile-deep-link-banner {
margin: 30px;
text-align: center;
.crayons-card {
max-width: 500px;
margin: 30px auto;
}
}
#mobile-deep-link-spinner {
svg {
margin: 30px auto;
}
}

View file

@ -0,0 +1,23 @@
class DeepLinksController < ApplicationController
def mobile; end
# Apple Application Site Association
def aasa
# TODO: [@fdoxyz] Replace these hardcoded identifiers with configurations
# creators can use to customize their Forems - `/admin/consumer_apps`
supported_apps = ["R9SWHSQNV8.com.forem.app"]
supported_apps << "R9SWHSQNV8.to.dev.ios" if SiteConfig.dev_to?
render json: {
applinks: {
apps: supported_apps,
details: supported_apps.map { |app_id| { appID: app_id, paths: ["/*"] } }
},
activitycontinuation: {
apps: supported_apps
},
webcredentials: {
apps: supported_apps
}
}
end
end

View file

@ -67,6 +67,16 @@ module URL
ActionController::Base.helpers.image_url(image_name, host: host)
end
# Creates a deep link URL (for mobile) to a page in the current Forem and it
# relies on a UDL server to bounce back mobile users to the local `/r/mobile`
# fallback page. More details here: https://github.com/forem/udl-server
#
# @param path [String] the target path to deep link
def self.deep_link(path)
target_path = CGI.escape(url("/r/mobile?deep_link=#{path}"))
"https://forem-udl-server.herokuapp.com/?r=#{target_path}"
end
def self.organization(organization)
url(organization.slug)
end

View file

@ -0,0 +1,48 @@
<div id="mobile-deep-link-spinner" class="flex flex-col" aria-live="polite">
<svg class="crayons-icon crayons-spinner" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.364 5.636L16.95 7.05A7 7 0 1019 12h2a9 9 0 11-2.636-6.364z" fill="currentColor" class="grow-1"></path></svg>
<p class="align-center">Opening the mobile app...</p>
</div>
<div class="mobile-deep-link-banner invisible" role="alert">
<div class="crayons-notice crayons-notice--warning">
<p>
Whoops! Did you get stuck trying to open the mobile app?
</p>
<p class="mt-5">
Make sure it's installed and try again. If the problem persists contact <a href="mailto:<%= SiteConfig.email_addresses[:contact] %>"><%= SiteConfig.email_addresses[:contact] %></a>
</p>
</div>
<div class="crayons-card">
<div class="flex w-100">
<a href="javascript:history.back()" class="crayons-btn crayons-btn--l crayons-btn--secondary grow-1 whitespace-nowrap mt-5 mx-5">
Take me back
</a>
</div>
<div class="flex w-100">
<a id="link-to-mobile-install-retry" href="/" class="crayons-btn crayons-btn--l crayons-btn--secondary grow-1 whitespace-nowrap my-5 mx-5">
Try again
</a>
</div>
<div class="flex w-100">
<a id="link-to-mobile-install" href="/" class="crayons-btn crayons-btn--l grow-1 whitespace-nowrap mb-5 mx-5">
Install the app
</a>
</div>
</div>
</div>
<script type="text/javascript">
// The timeoutDelay is the amount of time (ms) the spinner will be visible for
// users. It is just "about 3 seconds", which gives users enough time to
// deep link into the mobile apps. It's chosen arbitrarily and can be edited
// in the future if other values want to be used. When the time is up the
// fallback options are revealed ("take me back", "try again" & "install").
const timeoutDelay = 3333;
setTimeout(function() {
document.getElementById('mobile-deep-link-spinner').remove();
document.querySelectorAll('.mobile-deep-link-banner')[0].classList.remove('invisible');
}, timeoutDelay);
</script>

View file

@ -104,15 +104,21 @@
</div>
<% end %>
<%= yield %>
<% if FeatureFlag.enabled?(:runtime_banner) || ENV['E2E'] == 'true' %>
<div class="Browser-iOS">
<div class="runtime-banner">
<a href="<%= URL.deep_link(request.path) %>" target="_blank" class="flex items-center flex-1">
<%= inline_svg_tag("logo-forem-app.svg", aria_hidden: true, class: "crayons-icon crayons-icon--default", width: 32, height: 32) %>
<span class="ml-2">Open with the Forem app...</span>
</a>
<button type="button" class="runtime-banner__dismiss crayons-btn crayons-btn--ghost crayons-btn--icon crayons-btn--inverted crayons-btn--s pl-2">
<%= inline_svg_tag("x.svg", aria: true, class: "crayons-icon", title: "Dismiss banner: Open with the Forem app") %>
</button>
</div>
</div>
<% end %>
</div>
</div>
<% if FeatureFlag.enabled?(:runtime_banner) %>
<div class="Browser-iOS Browser-Android runtime-banner">
<div class="runtime-banner__inner">
<span>Open with the Forem app</span>
</div>
</div>
<% end %>
<% unless internal_navigation? %>
<% cache("footer-and-signup-modal-#{user_signed_in?}-#{ForemInstance.deployed_at}-#{SiteConfig.admin_action_taken_at&.rfc3339}", expires_in: 24.hours) do %>
<%= render "layouts/footer" %>

View file

@ -21,6 +21,9 @@ Rails.application.routes.draw do
delete "/sign_out", to: "devise/sessions#destroy"
end
get "/r/mobile", to: "deep_links#mobile"
get "/.well-known/apple-app-site-association", to: "deep_links#aasa"
# [@forem/delightful] - all routes are nested under this optional scope to
# begin supporting i18n.
scope "(/locale/:locale)", defaults: { locale: nil } do

View file

@ -0,0 +1,72 @@
describe('Runtime Banner Deep Linking', () => {
const runtimeStub = {
onBeforeLoad: (win) => {
Object.defineProperty(win.navigator, 'userAgent', {
value:
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1',
});
Object.defineProperty(win.navigator, 'platform', { value: 'iPhone' });
},
};
beforeEach(() => {
cy.testSetup();
cy.visit(`/`, runtimeStub);
});
it('should include the correct deep_link param in the banner link', () => {
// When visiting the root path the banner should deep link into it
cy.get('.runtime-banner > a')
.should('have.attr', 'href')
.and('contains', `deep_link%3D%2F`);
// NOTE: %3D%2F -> '=/' (URL Encoded)
// When visiting `/tags` the banner should deep link into `/tags`
cy.visit('/tags', runtimeStub).then(() => {
cy.get('.runtime-banner > a')
.should('have.attr', 'href')
.and('contains', `deep_link%3D%2Ftags`);
// NOTE: %3D%2Ftags -> '=/tags' (URL Encoded)
});
});
it('should redirect to custom scheme when someone taps on the banner', () => {
const deepLinkPath = '/custom_path';
const launchCustomSchemeDeepLinkStub = (url) => {
expect(url).to.eq(`com.forem.app:/${deepLinkPath}`);
};
cy.window()
.then((win) => {
cy.stub(
win,
'launchCustomSchemeDeepLink',
launchCustomSchemeDeepLinkStub,
);
})
.then(() => {
// After visiting the deep link redirect page (`/r/mobile`) it should
// immediately trigger the custom scheme, which gets caught by the stub
cy.visit(`/r/mobile?deep_link=${deepLinkPath}`);
});
});
it('should show a loading spinner and then the fallback page', () => {
const deepLinkPath = '/custom_path2';
cy.visit(`/r/mobile?deep_link=${deepLinkPath}`)
.then(() => {
// The loading spinner appears with the following text
cy.get('p').contains('Opening the mobile app...').should('be.visible');
})
.then(() => {
// After 3 seconds the following options appear visible
cy.get('p')
.contains('Whoops! Did you get stuck trying to open the mobile app?')
.should('be.visible');
cy.get('a').contains('Take me back').should('be.visible');
cy.get('a').contains('Try again').should('be.visible');
cy.get('a').contains('Install the app').should('be.visible');
// Also the loading text should not exist anymore
cy.get('p').contains('Opening the mobile app...').should('not.exist');
});
});
});

View file

@ -1,9 +0,0 @@
{
"applinks": {
"apps": [],
"details": [{
"appID": "R9SWHSQNV8.to.dev.ios",
"paths": ["*"]
}]
}
}

View file

@ -96,6 +96,16 @@ RSpec.describe URL, type: :lib do
end
end
describe ".deep_link" do
it "returns the correct URL for the root path" do
expect(described_class.deep_link("/")).to eq("https://forem-udl-server.herokuapp.com/?r=https%3A%2F%2Fdev.to%2Fr%2Fmobile%3Fdeep_link%3D%2F")
end
it "returns the correct URL for an explicit path" do
expect(described_class.deep_link("/sloan")).to eq("https://forem-udl-server.herokuapp.com/?r=https%3A%2F%2Fdev.to%2Fr%2Fmobile%3Fdeep_link%3D%2Fsloan")
end
end
describe ".local_image" do
let(:image_name) { "social-media-cover" }
let(:image_extension) { ".png" }

View file

@ -0,0 +1,39 @@
require "rails_helper"
RSpec.describe "Universal Links (Apple)", type: :request do
let(:aasa_route) { "/.well-known/apple-app-site-association" }
let(:forem_app_id) { "R9SWHSQNV8.com.forem.app" }
let(:dev_app_id) { "R9SWHSQNV8.to.dev.ios" }
describe "returns a valid Apple App Site Association file" do
context "with DEV app backwards compatibility" do
it "responds with applinks support for both" do
allow(SiteConfig).to receive(:dev_to?).and_return(true)
get aasa_route
json_response = JSON.parse(response.body)
both_app_ids = [forem_app_id, dev_app_id]
expect(response).to have_http_status(:ok)
expect(json_response.dig("applinks", "apps")).to match_array(both_app_ids)
json_response.dig("applinks", "details").each do |hash|
expect(both_app_ids).to include(hash["appID"])
expect(hash["paths"]).to match_array(["/*"])
end
end
end
context "when non-DEV Forem instance" do
it "responds with applinks support for Forem app" do
get aasa_route
json_response = JSON.parse(response.body)
expect(response).to have_http_status(:ok)
expect(json_response.dig("applinks", "apps")).to match_array([forem_app_id])
json_response.dig("applinks", "details").each do |hash|
expect(hash["appID"]).to eq(forem_app_id)
expect(hash["paths"]).to match_array(["/*"])
end
end
end
end
end