From d0c2c030577329cf885a21abb9a5c45a304a9061 Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Tue, 24 Jul 2018 14:04:23 -0300
Subject: [PATCH] First prototype of Espresso test infrastructure with
 automatic screenshoting

---
 bramble-android/build.gradle                  |   6 +
 briar-android/build.gradle                    |  18 +++
 briar-android/fastlane/Screengrabfile         |   5 +
 briar-android/proguard-test.txt               |  15 +++
 .../briar/android/BriarTestApplication.java   |  20 ++++
 .../briar/android/BriarTestComponent.java     |  23 ++++
 .../briar/android/settings/DarkThemeTest.java |  89 +++++++++++++++
 .../briar/android/test/BriarTestRunner.java   |  20 ++++
 .../briar/android/test/ScreenshotTest.java    | 108 ++++++++++++++++++
 .../briar/android/test/ViewActions.java       | 104 +++++++++++++++++
 .../briar/android/BriarApplicationImpl.java   |  13 ++-
 .../briar/android/util/UiUtils.java           |   2 +
 .../src/screenshot/AndroidManifest.xml        |  16 +++
 13 files changed, 435 insertions(+), 4 deletions(-)
 create mode 100644 briar-android/fastlane/Screengrabfile
 create mode 100644 briar-android/proguard-test.txt
 create mode 100644 briar-android/src/androidTest/java/org/briarproject/briar/android/BriarTestApplication.java
 create mode 100644 briar-android/src/androidTest/java/org/briarproject/briar/android/BriarTestComponent.java
 create mode 100644 briar-android/src/androidTest/java/org/briarproject/briar/android/settings/DarkThemeTest.java
 create mode 100644 briar-android/src/androidTest/java/org/briarproject/briar/android/test/BriarTestRunner.java
 create mode 100644 briar-android/src/androidTest/java/org/briarproject/briar/android/test/ScreenshotTest.java
 create mode 100644 briar-android/src/androidTest/java/org/briarproject/briar/android/test/ViewActions.java
 create mode 100644 briar-android/src/screenshot/AndroidManifest.xml

diff --git a/bramble-android/build.gradle b/bramble-android/build.gradle
index f7c9415ed4..0e6e49e176 100644
--- a/bramble-android/build.gradle
+++ b/bramble-android/build.gradle
@@ -15,6 +15,12 @@ android {
 		testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
 	}
 
+	buildTypes {
+		debug
+		screenshot
+		release
+	}
+
 	compileOptions {
 		sourceCompatibility JavaVersion.VERSION_1_8
 		targetCompatibility JavaVersion.VERSION_1_8
diff --git a/briar-android/build.gradle b/briar-android/build.gradle
index 77b2fcf6e1..7d0714bb7c 100644
--- a/briar-android/build.gradle
+++ b/briar-android/build.gradle
@@ -49,6 +49,14 @@ dependencies {
 	testImplementation "org.jmock:jmock-legacy:2.8.2"
 	testImplementation "org.hamcrest:hamcrest-library:1.3"
 	testImplementation "org.hamcrest:hamcrest-core:1.3"
+
+	def espressoVersion = '3.0.2'
+	androidTestImplementation "com.android.support.test.espresso:espresso-core:$espressoVersion"
+	androidTestImplementation "com.android.support.test.espresso:espresso-contrib:$espressoVersion"
+	androidTestImplementation "tools.fastlane:screengrab:1.1.0"
+	androidTestAnnotationProcessor "com.google.dagger:dagger-compiler:2.0.2"
+	androidTestCompileOnly 'javax.annotation:jsr250-api:1.0'
+	androidTestImplementation 'junit:junit:4.12'
 }
 
 dependencyVerification {
@@ -248,6 +256,7 @@ android {
 		def now = (long) (System.currentTimeMillis() / 1000)
 		buildConfigField "Long", "BuildTimestamp",
 				"${getStdout(['git', 'log', '-n', '1', '--format=%ct'], now)}000L"
+		testInstrumentationRunner 'org.briarproject.briar.android.test.BriarTestRunner'
 	}
 
 	buildTypes {
@@ -260,6 +269,13 @@ android {
 			crunchPngs false
 			proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
 		}
+		screenshot {
+			initWith debug
+			applicationIdSuffix ".screenshot"
+			resValue "string", "app_package", "org.briarproject.briar.android.screenshot"
+			resValue "string", "app_name", "Briar Screenshot"
+			testProguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt', 'proguard-test.txt'
+		}
 		release {
 			shrinkResources false
 			minifyEnabled true
@@ -268,6 +284,8 @@ android {
 		}
 	}
 
+	testBuildType "screenshot"
+
 	compileOptions {
 		sourceCompatibility JavaVersion.VERSION_1_8
 		targetCompatibility JavaVersion.VERSION_1_8
diff --git a/briar-android/fastlane/Screengrabfile b/briar-android/fastlane/Screengrabfile
new file mode 100644
index 0000000000..690bc4cc89
--- /dev/null
+++ b/briar-android/fastlane/Screengrabfile
@@ -0,0 +1,5 @@
+app_package_name "org.briarproject.briar.android.screenshot"
+locales ['en-US']
+app_apk_path "build/outputs/apk/screenshot/briar-android-screenshot.apk"
+tests_apk_path "build/outputs/apk/androidTest/screenshot/briar-android-screenshot-androidTest.apk"
+test_instrumentation_runner "org.briarproject.briar.android.test.BriarTestRunner"
\ No newline at end of file
diff --git a/briar-android/proguard-test.txt b/briar-android/proguard-test.txt
new file mode 100644
index 0000000000..7007db0c24
--- /dev/null
+++ b/briar-android/proguard-test.txt
@@ -0,0 +1,15 @@
+-dontwarn android.test.**
+-dontwarn android.support.test.**
+-dontnote android.support.test.**
+-dontwarn com.googlecode.eyesfree.compat.CompatUtils
+
+-keep class org.xmlpull.v1.** { *; }
+-dontwarn org.xmlpull.v1.**
+
+-keep class org.junit.** { *; }
+-dontwarn org.junit.**
+
+-keep class junit.** { *; }
+-dontwarn junit.**
+
+-dontwarn org.briarproject.briar.android.BriarTestApplication
\ No newline at end of file
diff --git a/briar-android/src/androidTest/java/org/briarproject/briar/android/BriarTestApplication.java b/briar-android/src/androidTest/java/org/briarproject/briar/android/BriarTestApplication.java
new file mode 100644
index 0000000000..8bdd5b63a6
--- /dev/null
+++ b/briar-android/src/androidTest/java/org/briarproject/briar/android/BriarTestApplication.java
@@ -0,0 +1,20 @@
+package org.briarproject.briar.android;
+
+import org.briarproject.bramble.BrambleCoreModule;
+import org.briarproject.briar.BriarCoreModule;
+
+public class BriarTestApplication extends BriarApplicationImpl {
+
+	@Override
+	protected AndroidComponent createApplicationComponent() {
+		AndroidComponent component = DaggerBriarTestComponent.builder()
+				.appModule(new AppModule(this)).build();
+		// We need to load the eager singletons directly after making the
+		// dependency graphs
+		BrambleCoreModule.initEagerSingletons(component);
+		BriarCoreModule.initEagerSingletons(component);
+		AndroidEagerSingletons.initEagerSingletons(component);
+		return component;
+	}
+
+}
diff --git a/briar-android/src/androidTest/java/org/briarproject/briar/android/BriarTestComponent.java b/briar-android/src/androidTest/java/org/briarproject/briar/android/BriarTestComponent.java
new file mode 100644
index 0000000000..3b1ba59fd1
--- /dev/null
+++ b/briar-android/src/androidTest/java/org/briarproject/briar/android/BriarTestComponent.java
@@ -0,0 +1,23 @@
+package org.briarproject.briar.android;
+
+import org.briarproject.bramble.BrambleAndroidModule;
+import org.briarproject.bramble.BrambleCoreModule;
+import org.briarproject.briar.BriarCoreModule;
+import org.briarproject.briar.android.settings.DarkThemeTest;
+
+import javax.inject.Singleton;
+
+import dagger.Component;
+
+@Singleton
+@Component(modules = {
+		AppModule.class,
+		BriarCoreModule.class,
+		BrambleAndroidModule.class,
+		BrambleCoreModule.class
+})
+public interface BriarTestComponent extends AndroidComponent {
+
+	void inject(DarkThemeTest test);
+
+}
diff --git a/briar-android/src/androidTest/java/org/briarproject/briar/android/settings/DarkThemeTest.java b/briar-android/src/androidTest/java/org/briarproject/briar/android/settings/DarkThemeTest.java
new file mode 100644
index 0000000000..274c6e5c0b
--- /dev/null
+++ b/briar-android/src/androidTest/java/org/briarproject/briar/android/settings/DarkThemeTest.java
@@ -0,0 +1,89 @@
+package org.briarproject.briar.android.settings;
+
+import android.content.Intent;
+import android.support.test.espresso.contrib.DrawerActions;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+import android.view.Gravity;
+
+import junit.framework.AssertionFailedError;
+
+import org.briarproject.briar.R;
+import org.briarproject.briar.android.BriarTestComponent;
+import org.briarproject.briar.android.navdrawer.NavDrawerActivity;
+import org.briarproject.briar.android.test.ScreenshotTest;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static android.support.test.espresso.Espresso.onView;
+import static android.support.test.espresso.action.ViewActions.click;
+import static android.support.test.espresso.assertion.ViewAssertions.matches;
+import static android.support.test.espresso.contrib.DrawerMatchers.isClosed;
+import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static android.support.test.espresso.matcher.ViewMatchers.isRoot;
+import static android.support.test.espresso.matcher.ViewMatchers.withId;
+import static android.support.test.espresso.matcher.ViewMatchers.withText;
+import static org.briarproject.briar.android.test.ViewActions.waitForActivityToResume;
+
+@RunWith(AndroidJUnit4.class)
+public class DarkThemeTest extends ScreenshotTest {
+
+	@Rule
+	public ActivityTestRule<SettingsActivity> activityRule =
+			new ActivityTestRule<>(SettingsActivity.class);
+
+	@Override
+	protected void inject(BriarTestComponent component) {
+		component.inject(this);
+	}
+
+	@Before
+	public void waitForSignIn() {
+		onView(isRoot())
+				.perform(waitForActivityToResume(activityRule.getActivity()));
+	}
+
+	@Test
+	public void changeTheme() {
+		onView(withText(R.string.settings_button))
+				.check(matches(isDisplayed()));
+		onView(withText(R.string.pref_theme_title))
+				.check(matches(isDisplayed()))
+				.perform(click());
+		onView(withText(R.string.pref_theme_light))
+				.check(matches(isDisplayed()))
+				.perform(click());
+
+		screenshot("dark_theme_settings");
+
+		onView(withText(R.string.pref_theme_title))
+				.check(matches(isDisplayed()))
+				.perform(click());
+		onView(withText(R.string.pref_theme_dark))
+				.check(matches(isDisplayed()))
+				.perform(click());
+
+		Intent i =
+				new Intent(activityRule.getActivity(), NavDrawerActivity.class);
+		activityRule.getActivity().startActivity(i);
+
+		try {
+			onView(withId(R.id.expiryWarningClose))
+					.check(matches(isDisplayed()));
+			onView(withId(R.id.expiryWarningClose))
+					.perform(click());
+		} catch (AssertionFailedError e){
+			// TODO remove try block when starting with fresh account
+			// ignore since we already removed the expiry warning
+		}
+
+		onView(withId(R.id.drawer_layout))
+				.check(matches(isClosed(Gravity.LEFT)))
+				.perform(DrawerActions.open());
+
+		screenshot("dark_theme_nav_drawer");
+	}
+
+}
diff --git a/briar-android/src/androidTest/java/org/briarproject/briar/android/test/BriarTestRunner.java b/briar-android/src/androidTest/java/org/briarproject/briar/android/test/BriarTestRunner.java
new file mode 100644
index 0000000000..cefb68766f
--- /dev/null
+++ b/briar-android/src/androidTest/java/org/briarproject/briar/android/test/BriarTestRunner.java
@@ -0,0 +1,20 @@
+package org.briarproject.briar.android.test;
+
+import android.app.Application;
+import android.content.Context;
+import android.support.test.runner.AndroidJUnitRunner;
+
+import org.briarproject.briar.android.BriarTestApplication;
+
+public class BriarTestRunner extends AndroidJUnitRunner {
+
+	@Override
+	public Application newApplication(ClassLoader cl, String className,
+			Context context)
+			throws InstantiationException, IllegalAccessException,
+			ClassNotFoundException {
+		return super.newApplication(cl, BriarTestApplication.class.getName(),
+				context);
+	}
+
+}
diff --git a/briar-android/src/androidTest/java/org/briarproject/briar/android/test/ScreenshotTest.java b/briar-android/src/androidTest/java/org/briarproject/briar/android/test/ScreenshotTest.java
new file mode 100644
index 0000000000..b5a71e845d
--- /dev/null
+++ b/briar-android/src/androidTest/java/org/briarproject/briar/android/test/ScreenshotTest.java
@@ -0,0 +1,108 @@
+package org.briarproject.briar.android.test;
+
+import android.support.test.InstrumentationRegistry;
+import android.support.test.espresso.NoMatchingViewException;
+import android.util.Log;
+
+import org.briarproject.briar.R;
+import org.briarproject.briar.android.BriarTestApplication;
+import org.briarproject.briar.android.BriarTestComponent;
+import org.junit.Before;
+import org.junit.ClassRule;
+
+import tools.fastlane.screengrab.Screengrab;
+import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy;
+import tools.fastlane.screengrab.locale.LocaleTestRule;
+
+import static android.support.test.espresso.Espresso.onView;
+import static android.support.test.espresso.action.ViewActions.click;
+import static android.support.test.espresso.action.ViewActions.typeText;
+import static android.support.test.espresso.assertion.ViewAssertions.matches;
+import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static android.support.test.espresso.matcher.ViewMatchers.withId;
+import static android.support.test.espresso.matcher.ViewMatchers.withText;
+import static tools.fastlane.screengrab.Screengrab.setDefaultScreenshotStrategy;
+
+public abstract class ScreenshotTest {
+
+	@ClassRule
+	public static final LocaleTestRule localeTestRule = new LocaleTestRule();
+
+	@Before
+	public void setupScreenshots() {
+		setDefaultScreenshotStrategy(new UiAutomatorScreenshotStrategy());
+	}
+
+	private static final String USERNAME = "test";
+	private static final String PASSWORD = "123456";
+
+	private final BriarTestApplication app =
+			(BriarTestApplication) InstrumentationRegistry.getTargetContext()
+					.getApplicationContext();
+
+	protected abstract void inject(BriarTestComponent component);
+
+	/**
+	 * Signs the user in.
+	 *
+	 * Note that you need to wait for your UI to show up after this.
+	 * See {@link ViewActions#waitForActivityToResume} for one way to do it.
+	 */
+	@Before
+	public void signIn() throws Exception {
+		inject((BriarTestComponent) app.getApplicationComponent());
+
+		try {
+			onView(withId(R.id.edit_password))
+					.check(matches(isDisplayed()))
+					.perform(typeText(PASSWORD));
+			onView(withId(R.id.btn_sign_in))
+					.check(matches(isDisplayed()))
+					.perform(click());
+		} catch (NoMatchingViewException e) {
+			// we start from a blank state and have no account, yet
+			createAccount();
+		}
+	}
+
+	private void createAccount() {
+		// TODO use AccountManager to start with fresh account
+		// TODO move this below into a dedicated test for SetupActivity
+
+		// Enter username
+		onView(withText(R.string.setup_title))
+				.check(matches(isDisplayed()));
+		onView(withId(R.id.nickname_entry))
+				.check(matches(isDisplayed()))
+				.perform(typeText(USERNAME));
+		onView(withId(R.id.next))
+				.check(matches(isDisplayed()))
+				.perform(click());
+
+		// Enter password
+		onView(withId(R.id.password_entry))
+				.check(matches(isDisplayed()))
+				.perform(typeText(PASSWORD));
+		onView(withId(R.id.password_confirm))
+				.check(matches(isDisplayed()))
+				.perform(typeText(PASSWORD));
+		onView(withId(R.id.next))
+				.check(matches(isDisplayed()))
+				.perform(click());
+		onView(withId(R.id.progress))
+				.check(matches(isDisplayed()));
+	}
+
+	protected void screenshot(String name) {
+		try {
+			Screengrab.screenshot(name);
+		} catch (RuntimeException e) {
+			if (!e.getMessage().equals("Unable to capture screenshot."))
+				throw e;
+			// The tests should still pass when run from AndroidStudio
+			// without manually granting permissions like fastlane does.
+			Log.w("Screengrab", "Permission to write screenshot is missing.");
+		}
+	}
+
+}
diff --git a/briar-android/src/androidTest/java/org/briarproject/briar/android/test/ViewActions.java b/briar-android/src/androidTest/java/org/briarproject/briar/android/test/ViewActions.java
new file mode 100644
index 0000000000..806124d1b6
--- /dev/null
+++ b/briar-android/src/androidTest/java/org/briarproject/briar/android/test/ViewActions.java
@@ -0,0 +1,104 @@
+package org.briarproject.briar.android.test;
+
+import android.app.Activity;
+import android.support.test.espresso.PerformException;
+import android.support.test.espresso.UiController;
+import android.support.test.espresso.ViewAction;
+import android.support.test.runner.lifecycle.ActivityLifecycleMonitor;
+import android.support.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
+import android.view.View;
+
+import org.hamcrest.Matcher;
+
+import java.util.concurrent.TimeoutException;
+
+import static android.support.test.espresso.matcher.ViewMatchers.isRoot;
+import static android.support.test.espresso.util.HumanReadables.describe;
+import static android.support.test.espresso.util.TreeIterables.breadthFirstViewTraversal;
+import static android.support.test.runner.lifecycle.Stage.RESUMED;
+import static java.lang.System.currentTimeMillis;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+public class ViewActions {
+
+	private final static long TIMEOUT_MS = SECONDS.toMillis(5);
+	private final static long WAIT_MS = 50;
+
+	public static ViewAction waitUntilMatches(Matcher<View> viewMatcher) {
+		return waitUntilMatches(viewMatcher, TIMEOUT_MS);
+	}
+
+	private static ViewAction waitUntilMatches(Matcher<View> viewMatcher,
+			long timeout) {
+		return new ViewAction() {
+			@Override
+			public Matcher<View> getConstraints() {
+				return isRoot();
+			}
+
+			@Override
+			public String getDescription() {
+				return "Wait for view matcher " + viewMatcher +
+						" to match within " + timeout + " milliseconds.";
+			}
+
+			@Override
+			public void perform(final UiController uiController,
+					final View view) {
+				uiController.loopMainThreadUntilIdle();
+				long endTime = currentTimeMillis() + timeout;
+
+				do {
+					for (View child : breadthFirstViewTraversal(view)) {
+						if (viewMatcher.matches(child)) return;
+					}
+					uiController.loopMainThreadForAtLeast(WAIT_MS);
+				}
+				while (currentTimeMillis() < endTime);
+
+				throw new PerformException.Builder()
+						.withActionDescription(getDescription())
+						.withViewDescription(describe(view))
+						.withCause(new TimeoutException())
+						.build();
+			}
+		};
+	}
+
+	public static ViewAction waitForActivityToResume(Activity activity) {
+		return new ViewAction() {
+			@Override
+			public Matcher<View> getConstraints() {
+				return isRoot();
+			}
+
+			@Override
+			public String getDescription() {
+				return "Wait for activity " + activity.getClass().getName() +
+						" to resume within " + TIMEOUT_MS + " milliseconds.";
+			}
+
+			@Override
+			public void perform(final UiController uiController,
+					final View view) {
+				uiController.loopMainThreadUntilIdle();
+				long endTime = currentTimeMillis() + TIMEOUT_MS;
+				ActivityLifecycleMonitor lifecycleMonitor =
+						ActivityLifecycleMonitorRegistry.getInstance();
+				do {
+					if (lifecycleMonitor.getLifecycleStageOf(activity) ==
+							RESUMED) return;
+					uiController.loopMainThreadForAtLeast(WAIT_MS);
+				}
+				while (currentTimeMillis() < endTime);
+
+				throw new PerformException.Builder()
+						.withActionDescription(getDescription())
+						.withViewDescription(describe(view))
+						.withCause(new TimeoutException())
+						.build();
+			}
+		};
+	}
+
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/BriarApplicationImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/BriarApplicationImpl.java
index 08fc14b5da..e56adf30ec 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/BriarApplicationImpl.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/BriarApplicationImpl.java
@@ -109,15 +109,20 @@ public class BriarApplicationImpl extends Application
 
 		LOG.info("Created");
 
-		applicationComponent = DaggerAndroidComponent.builder()
+		applicationComponent = createApplicationComponent();
+	}
+
+	protected AndroidComponent createApplicationComponent() {
+		AndroidComponent androidComponent = DaggerAndroidComponent.builder()
 				.appModule(new AppModule(this))
 				.build();
 
 		// We need to load the eager singletons directly after making the
 		// dependency graphs
-		BrambleCoreModule.initEagerSingletons(applicationComponent);
-		BriarCoreModule.initEagerSingletons(applicationComponent);
-		AndroidEagerSingletons.initEagerSingletons(applicationComponent);
+		BrambleCoreModule.initEagerSingletons(androidComponent);
+		BriarCoreModule.initEagerSingletons(androidComponent);
+		AndroidEagerSingletons.initEagerSingletons(androidComponent);
+		return androidComponent;
 	}
 
 	@Override
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java
index ee30d8d37f..2485f81c95 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java
@@ -55,6 +55,7 @@ import static android.text.format.DateUtils.FORMAT_SHOW_DATE;
 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
 import static android.text.format.DateUtils.WEEK_IN_MILLIS;
 import static org.briarproject.briar.BuildConfig.APPLICATION_ID;
+import static org.briarproject.briar.BuildConfig.BUILD_TYPE;
 import static org.briarproject.briar.android.TestingConstants.EXPIRY_DATE;
 
 @MethodsNotNullByDefault
@@ -174,6 +175,7 @@ public class UiUtils {
 
 	public static boolean needsDozeWhitelisting(Context ctx) {
 		if (SDK_INT < 23) return false;
+		if (BUILD_TYPE.equals("screenshot")) return false;
 		PowerManager pm = (PowerManager) ctx.getSystemService(POWER_SERVICE);
 		String packageName = ctx.getPackageName();
 		if (pm == null) throw new AssertionError();
diff --git a/briar-android/src/screenshot/AndroidManifest.xml b/briar-android/src/screenshot/AndroidManifest.xml
new file mode 100644
index 0000000000..266a92823b
--- /dev/null
+++ b/briar-android/src/screenshot/AndroidManifest.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest
+	package="org.briarproject.briar"
+	xmlns:android="http://schemas.android.com/apk/res/android">
+
+	<!-- Allows unlocking your device and activating its screen so UI tests can succeed -->
+	<uses-permission android:name="android.permission.DISABLE_KEYGUARD"/>
+	<uses-permission android:name="android.permission.WAKE_LOCK"/>
+
+	<!-- Allows for storing and retrieving screenshots -->
+	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+
+	<!-- Allows changing locales -->
+	<uses-permission android:name="android.permission.CHANGE_CONFIGURATION" />
+</manifest>
-- 
GitLab