diff --git a/.github/workflows/firebase-testing.yml b/.github/workflows/firebase-testing.yml new file mode 100644 index 0000000..1887134 --- /dev/null +++ b/.github/workflows/firebase-testing.yml @@ -0,0 +1,46 @@ +name: firebase-testing +on: [push, workflow_dispatch] +jobs: + test-app: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up gcloud Cloud SDK environment + # You may pin to the exact commit or the version. + # uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 + uses: google-github-actions/setup-gcloud@v0.2.0 + with: + # Version of the gcloud SDK to install. If unspecified or set to "latest", the latest available gcloud SDK version for the target platform will be installed. Example: "290.0.1". + version: latest + # Service account email address to use for authentication. This is required for legacy .p12 keys but can be omitted for .json keys. This is usually of the format @.iam.gserviceaccount.com. + + # Service account key to use for authentication. This should be the JSON formatted private key which can be exported from the Cloud Console. The value can be raw or base64-encoded. + service_account_key: ${{ secrets.GCLOUD_KEY }} + # ID of the Google Cloud project. If provided, this will configure gcloud to use this project ID by default for commands. Individual commands can still override the project using the --project flag which takes precedence. + project_id: gn-test-firebase-test-lab + # Export the provided credentials as Google Default Application Credentials. This will make the credentials available to later steps via the GOOGLE_APPLICATION_CREDENTIALS environment variable. Future steps that consume Default Application Credentials will automatically detect and use these credentials. + export_default_credentials: true + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Copying the appConfig file from androidTest to main + run: cp -f ./app/src/androidTest/assets/appConfig.json ./app/src/main/assets/appConfig.json + + - name: Add dependencies in app/build.gradle + run: sed -i "s/dependencies *\n*{/dependencies {\nandroidTestImplementation 'androidx.test.ext:junit:1.1.2'\nandroidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'\nandroidTestImplementation 'androidx.test.espresso:espresso-contrib:3.3.0'\nandroidTestImplementation 'androidx.test.espresso:espresso-web:3.3.0'\nandroidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'\n/" ./app/build.gradle + + - name: Add test runner in defaultConfig in app/build.gradle + run: sed -i "s/defaultConfig *\n*{/defaultConfig {\ntestInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'/" ./app/build.gradle + + - name: Adding Helper method in GoNativeWebviewClient.java + run: sed -i "s/super.onPageFinished *( *view *, *url *) *;/super.onPageFinished(view, url);\nHelperClass.newLoad++;/" ./app/src/normal/java/io/gonative/android/GoNativeWebviewClient.java + + - name: Copying HelperClass.java from androidTest/assets to normal/java/io/gonative/android/ + run: cp -f ./app/src/androidTest/assets/HelperClass.java ./app/src/normal/java/io/gonative/android/HelperClass.java + + - name: Build the App + run: ./gradlew assembleDebug assembleAndroidTest + + - name: Testing the App + run: gcloud firebase test android run --type instrumentation --app ./app/build/outputs/apk/normal/debug/app-normal-debug.apk --test ./app/build/outputs/apk/androidTest/normal/debug/app-normal-debug-androidTest.apk --device model=flo,version=21,locale=en,orientation=portrait --device model=hammerhead,version=23,locale=en,orientation=portrait --device model=griffin,version=24,locale=en,orientation=portrait --device model=G8142,version=25,locale=en,orientation=portrait --device model=star2qlteue,version=26,locale=en,orientation=portrait --device model=walleye,version=27,locale=en,orientation=portrait --device model=OnePlus5T,version=28,locale=en,orientation=portrait --device model=x1q,version=29,locale=en,orientation=portrait --device model=flame,version=30,locale=en,orientation=portrait --results-bucket cloud-test-gn-test-firebase-test-lab --timeout 300s diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..855669b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*~ +.idea/ +.gradle/ +*.iml +local.properties +app/build/ +build/ +captures/ +.DS_Store +plugins/ diff --git a/AppIcon b/AppIcon new file mode 100644 index 0000000..1344998 Binary files /dev/null and b/AppIcon differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f486a1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +## 2014-01-04 + +- Fix a crash on reload with no page loaded. + +## 2015-01-02 + +- Update to latest gradle and build tools versions, making the project compatible with Android Studio 1.0. +- Fix bugs related to syncing of tabs with sidebar menu. + +## 2014-12-23 + +- Allow setting of viewport while preserving ability to zoom. +- Allow dynamic config of navigation title image URLs. +- Various bug fixes involving javascript after page load, and tab coloring, tab animations, and a crash on application resume. + +## 2014-12-22 + +- Fix various threading bugs where UI methods were called from non-UI threads. + +## 2014-12-05 + +- Support showing the navigation title image on specific URLs. + +## 2014-12-03 + +- Support customizing user agent per URL. +- Add color styling options for tabs. + +## 2014-11-30 + +- New tabs with better material design and animations. +- Fix some automatic icon generation scripts. + +## 2014-11-26 + +- Fix a crash involving webview pools. + +## 2014-11-25 + +- Add support for custom actions in action bar. diff --git a/HeaderImage b/HeaderImage new file mode 100644 index 0000000..198c8f3 Binary files /dev/null and b/HeaderImage differ diff --git a/NotificationIcon b/NotificationIcon new file mode 100644 index 0000000..629eac8 Binary files /dev/null and b/NotificationIcon differ diff --git a/REVISION b/REVISION new file mode 100644 index 0000000..27adc48 --- /dev/null +++ b/REVISION @@ -0,0 +1 @@ +bea6b380227dae2dae6694deb038c7a854605c80 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..08e808f --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,184 @@ +import groovy.json.JsonSlurper + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +//[enabled by builder] apply plugin: 'com.google.gms.google-services' +//[enabled by builder] apply plugin: 'com.google.firebase.crashlytics' + +ext { + fbAppId = "" + fbClientToken = "" + onesignalAppId = "" + adMobAppId = "" + googleServiceInvalid = "false" + auth0Domain = "" + auth0Scheme = "" +} + +task parseAppConfig { + def jsonFile = file('src/main/assets/appConfig.json') + def parsedJson = new JsonSlurper().parseText(jsonFile.text) + if (parsedJson.services.facebook) { + if (parsedJson.services.facebook.appId) { + fbAppId = parsedJson.services.facebook.appId + } + if (parsedJson.services.facebook.clientToken) { + fbClientToken = parsedJson.services.facebook.clientToken + } + } + if (parsedJson.services.socialLogin && parsedJson.services.socialLogin.facebookLogin) { + if (parsedJson.services.socialLogin.facebookLogin.appId) { + fbAppId = parsedJson.services.socialLogin.facebookLogin.appId + } + if (parsedJson.services.socialLogin.facebookLogin.clientToken) { + fbClientToken = parsedJson.services.socialLogin.facebookLogin.clientToken + } + } + if (parsedJson.services.oneSignal && parsedJson.services.oneSignal.applicationId) { + onesignalAppId = parsedJson.services.oneSignal.applicationId + } + if (parsedJson.services.admob && parsedJson.services.admob.admobAndroid && parsedJson.services.admob.admobAndroid.applicationId) { + adMobAppId = parsedJson.services.admob.admobAndroid.applicationId + } + if (parsedJson.services.braze) { + if (parsedJson.services.braze.androidApiKey) { + gradle.ext.set("braze_api_key", parsedJson.services.braze.androidApiKey) + } + if (parsedJson.services.braze.androidEndpointKey) { + gradle.ext.set("braze_endpoint_key", parsedJson.services.braze.androidEndpointKey) + } + } + if (parsedJson.services.auth0) { + if (parsedJson.services.auth0.domain) { + auth0Domain = parsedJson.services.auth0.domain + } + if (parsedJson.services.auth0.scheme) { + auth0Scheme = parsedJson.services.auth0.scheme + } + } +} + +task checkGoogleService { + plugins.withId("com.google.gms.google-services") { + def googleServiceJsonFile = file('google-services.json') + if (project.file(googleServiceJsonFile).exists()) { + if (googleServiceJsonFile.text.isEmpty()) { + googleServiceInvalid = "true" + } + } else { + googleServiceInvalid = "true" + } + } +} + +build.dependsOn parseAppConfig +build.dependsOn checkGoogleService + +android { + defaultConfig { + compileSdk 34 + minSdkVersion 23 + targetSdkVersion 34 + applicationId "co.median.android.lnooda" + versionCode 6 + multiDexEnabled true + vectorDrawables.useSupportLibrary = true + + manifestPlaceholders = [manifestApplicationId: "${applicationId}", + onesignal_app_id: onesignalAppId, + onesignal_google_project_number: "", + admob_app_id: adMobAppId, + facebook_app_id: fbAppId, + facebook_client_token: fbClientToken, + auth0Domain: auth0Domain, auth0Scheme: auth0Scheme ] + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + signingConfigs { + release { + storeFile file("../../release.keystore") + storePassword "password" + keyAlias "release" + keyPassword "password" + } + upload { + storeFile file("../../upload.keystore") + storePassword "password" + keyAlias "upload" + keyPassword "password" + } + } + + buildTypes { + debug { + applicationIdSuffix ".debug" + } + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-project.txt' + zipAlignEnabled true + debuggable project.getProperties().get("enableLogsInRelease").toBoolean() + signingConfig signingConfigs.release + } + upload { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-project.txt' + zipAlignEnabled true + matchingFallbacks = ['release'] + debuggable project.getProperties().get("enableLogsInRelease").toBoolean() + signingConfig signingConfigs.upload + } + buildTypes.each { + it.buildConfigField 'boolean', 'GOOGLE_SERVICE_INVALID', googleServiceInvalid + } + } + + flavorDimensions "webview" + + productFlavors { + normal { + dimension "webview" + } + } + namespace 'co.median.android' + testNamespace '${applicationId}.test' + buildFeatures { + buildConfig true + } +} + +dependencies { + /**** dependencies used by all apps ****/ + implementation "androidx.core:core-ktx:1.12.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'com.squareup:seismic:1.0.2' + implementation 'androidx.webkit:webkit:1.10.0' + implementation 'androidx.core:core-splashscreen:1.0.1' + implementation "com.github.gonativeio:gonative-icons:$iconsVersion" + implementation "com.github.gonativeio:gonative-android-core:$coreVersion" + /**** end all apps ****/ + + /**** add-on module dependencies ****/ + /**** end modules ****/ + + /**** Google Android and Play Services dependencies ****/ + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.browser:browser:1.7.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation "androidx.drawerlayout:drawerlayout:1.2.0" + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + /**** end google ****/ + + /**** local dependencies ****/ + implementation fileTree(dir: 'libs', include: '*.jar') + implementation fileTree(dir: 'libs', include: '*.aar') + /**** end local ****/ +} + +apply from: file("../plugins.gradle"); applyNativeModulesAppBuildGradle(project) \ No newline at end of file diff --git a/app/proguard-project.txt b/app/proguard-project.txt new file mode 100644 index 0000000..68dbf68 --- /dev/null +++ b/app/proguard-project.txt @@ -0,0 +1,60 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# 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 *; +#} + +# webview interfaces + +-keepclassmembers class co.median.android.ProfilePicker$ProfileJsBridge { + ; +} +-keepclassmembers class co.median.android.MainActivity$StatusCheckerBridge { + ; +} +-keepattributes JavascriptInterface + +# stuff for google play services +-keep class * extends java.util.ListResourceBundle { + protected Object[][] getContents(); +} + +-keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable { + public static final *** NULL; +} + +-keepnames @com.google.android.gms.common.annotation.KeepName class * +-keepclassmembernames class * { + @com.google.android.gms.common.annotation.KeepName *; +} + +-keepnames class * implements android.os.Parcelable { + public static final ** CREATOR; +} + +# Google Cloud Messaging +-keep class com.google.android.gms.** { *; } +-keep interface com.google.android.gms.** { *; } + +# appcompat library +-dontwarn android.support.v4.** +-keep class android.support.v4.** { *; } +-keep interface android.support.v4.** { *; } +-dontwarn android.support.v7.** +-keep class android.support.v7.** { *; } +-keep interface android.support.v7.** { *; } diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..dc5ed4d --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/androidTest/assets/HelperClass.java b/app/src/androidTest/assets/HelperClass.java new file mode 100644 index 0000000..20701b0 --- /dev/null +++ b/app/src/androidTest/assets/HelperClass.java @@ -0,0 +1,5 @@ +package io.median.android; + +public class HelperClass { + public volatile static int newLoad = 0; +} diff --git a/app/src/androidTest/assets/appConfig.json b/app/src/androidTest/assets/appConfig.json new file mode 100644 index 0000000..f73f66d --- /dev/null +++ b/app/src/androidTest/assets/appConfig.json @@ -0,0 +1,153 @@ +{ + "general": { + "userAgentAdd": "gonative", + "initialUrl": "https://gonative.io", + "appName": "GoNative.io" + }, + "navigation": { + "androidPullToRefresh": true, + "sidebarNavigation": { + "sidebarEnabledRegex": null, + "menus": [{ + "name": "default", + "items": [{ + "url": "https://gonative.io", + "label": "Home", + "subLinks": [] + }, { + "url": "https://gonative.io/about", + "label": "About", + "subLinks": [] + }, { + "url": "https://gonative.io/examples", + "label": "Examples", + "subLinks": [] + }], + "active": true + }] + }, + "tabNavigation": { + "tabSelectionConfig": [{ + "id": "1", + "regex": ".*about.*" + }], + "tabMenus": [{ + "id": "1", + "items": [{ + "icon": "fa-cloud", + "label": "Tab 1", + "url": "https://www.gonative.io/pricing" + }, { + "icon": "fa-globe", + "label": "Tab 2", + "url": "https://www.gonative.io/examples" + }, { + "icon": "fa-users", + "label": "Tab 3", + "url": "javascript:alert('You selected tab 3. These tabs are only shown on the about page')" + }] + }], + "active": true + }, + "actionConfig": { + "active": true, + "actions": [{ + "id": "exampleActions", + "items": [{ + "label": "Globe", + "icon": "fa-globe", + "url": "javascript:alert('You tapped the globe! It only appears on the Examples page')" + }] + }], + "actionSelection": [{ + "regex": ".*/examples.*", + "id": "exampleActions" + }] + }, + "regexInternalExternal": { + "rules": [{ + "regex": "https?://([-\\w]+\\.)*facebook\\.com/login.php.*", + "internal": true + }, { + "regex": "https?://([-\\w]+\\.)*facebook\\.com/pages/.*", + "internal": false + }, { + "regex": "https?://([-\\w]+\\.)*facebook\\.com/sharer\\.php.*", + "internal": false + }, { + "regex": "https?://([-\\w]+\\.)*plus\\.google\\.com/share.*", + "internal": false + }, { + "regex": "https?://([-\\w]+\\.)*twitter\\.com/intent/.*", + "internal": false + }, { + "regex": "https?://([-\\w]+\\.)*gonative\\.io/?.*", + "internal": true + }, { + "regex": "https?://([-\\w]+\\.)*google\\.com/?.*", + "internal": true + }, { + "regex": "https://gonative-test-web.web.app/.*", + "internal": true + }, + { + "regex": "https://us-central1-gn-test-firebase-test-lab.cloudfunctions.net/.*", + "internal": true + }], + "active": true + }, + "redirects": [{ + "from": "https://example.com/from/", + "to": "https://example.com/to/" + }] + }, + "forms": { + "search": { + "active": true, + "searchTemplateURL": "https://us-central1-gn-test-firebase-test-lab.cloudfunctions.net/gnTestSearch?q=" + } + }, + "styling": { + "showActionBar": true, + "showNavigationBar": true, + "iosTitleColor": "#333333", + "iosTintColor": "#0091fe", + "androidTheme": "Light.DarkActionBar", + "androidSidebarBackgroundColor": "#111111", + "androidSidebarForegroundColor": "#d0d0d0", + "androidHideTitleInActionBar": false, + "androidPullToRefreshColor": "#333333", + "androidTabBarBackgroundColor": "#fefefe", + "androidTabBarTextColor": "#747474", + "androidTabBarIndicatorColorx": "#2f79fe", + "androidShowSplash": true, + "androidShowSplashMaxTime": null, + "androidShowSplashForceTime": null, + "disableAnimations": false, + "menuAnimationDuration": 0.15, + "transitionInteractiveDelayMax": 0.2 + }, + "permissions": { + "usesGeolocation": false, + "androidDownloadToPublicStorage": false + }, + "services": { + "oneSignal": { + "active": false, + "applicationId": "" + }, + "facebook": { + "active": false, + "appId": "", + "displayName": "" + }, + "registration": { + "active": false, + "endpoints": [{ + "url": "https://gonative.io/example_push_endpoint", + "dataType": "onesignal", + "urlRegex": ".*/loginfinished" + }] + } + } +} diff --git a/app/src/androidTest/java/com/median/testFiles/FirstTestClass.java b/app/src/androidTest/java/com/median/testFiles/FirstTestClass.java new file mode 100644 index 0000000..ec21525 --- /dev/null +++ b/app/src/androidTest/java/com/median/testFiles/FirstTestClass.java @@ -0,0 +1,105 @@ +package com.median.testFiles; + +import android.webkit.WebView; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import androidx.test.filters.SdkSuppress; +import androidx.test.uiautomator.UiDevice; +import org.json.JSONException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import io.median.android.MainActivity; +import io.median.android.R; +import io.median.median_core.AppConfig; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +@RunWith(AndroidJUnit4.class) +@SdkSuppress(minSdkVersion = 18) +@LargeTest +public class FirstTestClass{ + TestMethods testMethods; + AppConfig appConfig; + WebView webView; + private UiDevice uiDevice; + + @Rule + public ActivityScenarioRule activityScenarioRule = new ActivityScenarioRule<>(MainActivity.class); + + @Before + public void initMethod() throws InterruptedException { + for(int i = 0; i < 10; i++){ + try{ + uiDevice = UiDevice.getInstance(getInstrumentation()); + }catch (RuntimeException runtimeException){ + Thread.sleep(2000); + continue; + } + Thread.sleep(1000); + break; + } + activityScenarioRule.getScenario().onActivity(activity -> { + appConfig = AppConfig.getInstance(activity); + webView = activity.findViewById(R.id.webview); + testMethods = new TestMethods(activity, webView); + }); + } + + //Sidebar Navigation Test + @Test + public void testSidebarNavigation() throws InterruptedException, JSONException { + if(appConfig.showNavigationMenu && (appConfig.menus.get("default") != null)){ + if(appConfig.menus.get("default") == null) throw new RuntimeException("Navigation drawer list not found."); + else { + testMethods.waitForPageLoaded(); + testMethods.testNavigation(appConfig.menus.get("default")); + } + } + } + + //Tab Menu Navigation Test + @Test + public void testTabMenuNavigation() throws JSONException, InterruptedException { + if(appConfig.tabMenuRegexes.size() == 0) throw new RuntimeException("No Tab Menus found."); + else{ + testMethods.waitForPageLoaded(); + testMethods.m_testTabNavigation(appConfig.tabMenus, appConfig.tabMenuRegexes); + } + } + + //Internal vs External Links Test + @Test + public void testIvE() throws InterruptedException { + testMethods.waitForPageLoaded(); + testMethods.testInternalvExternalLinks(uiDevice); + } + + //Pull to Refresh Test + @Test + public void pullToRefresh() throws InterruptedException { + if(appConfig.pullToRefresh){ + testMethods.waitForPageLoaded(); + testMethods.testPullToRefresh(); + } + } + + //Search Button Test + @Test + public void testSearch() throws InterruptedException { + if(appConfig.searchTemplateUrl != null && !appConfig.searchTemplateUrl.isEmpty()){ + testMethods.waitForPageLoaded(); + testMethods.testSearchButton(); + } + } + + //Refresh Button Test + @Test + public void testRefreshButton() throws InterruptedException { + if(appConfig.showRefreshButton){ + testMethods.waitForPageLoaded(); + testMethods.testRefreshButton(); + } + } +} diff --git a/app/src/androidTest/java/com/median/testFiles/TestMethods.java b/app/src/androidTest/java/com/median/testFiles/TestMethods.java new file mode 100644 index 0000000..56dda2c --- /dev/null +++ b/app/src/androidTest/java/com/median/testFiles/TestMethods.java @@ -0,0 +1,183 @@ +package com.median.testFiles; +import android.webkit.WebView; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.espresso.PerformException; +import androidx.test.espresso.contrib.DrawerActions; +import androidx.test.espresso.web.webdriver.Locator; +import androidx.test.uiautomator.UiDevice; +import io.gonative.android.HelperClass; +import org.json.JSONArray; +import org.json.JSONException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.regex.Pattern; +import static androidx.test.espresso.Espresso.onData; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBackUnconditionally; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.pressImeActionButton; +import static androidx.test.espresso.action.ViewActions.swipeDown; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withResourceName; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.espresso.web.assertion.WebViewAssertions.webMatches; +import static androidx.test.espresso.web.sugar.Web.onWebView; +import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.getText; +import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.equalTo; +import io.median.android.MainActivity; +import io.median.android.R; + +public class TestMethods { + + public TestMethods(MainActivity mainActivity, WebView webView){ + m_mainActivity = mainActivity; + m_webView = webView; + } + + protected static String currentURL = "URL Not assigned yet"; + protected static String prevURL = "URL Not assigned yet"; + private final WebView m_webView; + private final MainActivity m_mainActivity; + + private void m_UpdateCurrentURL() throws InterruptedException { + m_mainActivity.runOnUiThread(() -> currentURL = m_webView.getOriginalUrl()); + Thread.sleep(1000); + } + + protected int getNewLoad(){ + return HelperClass.newLoad; + } + + public boolean isURL(String url){ + try { + new URL(url); + return true; + }catch (MalformedURLException e){ + return false; + } + } + + public void waitForPageLoaded() throws InterruptedException { + int counter = 0; + while (getNewLoad() == 0) { + if (counter >= 15) throw new RuntimeException("Page failed to load in less than 15 seconds."); + Thread.sleep(1000); + counter++; + } + m_UpdateCurrentURL(); + Thread.sleep(1000); + counter = 0; + while(currentURL == null && counter <= 10){ + Thread.sleep(1000); + counter++; + m_UpdateCurrentURL(); + } + if(currentURL != null) HelperClass.newLoad = 0; + else throw new RuntimeException("Current URL cannot be retrieved from the WebView."); + } + + public void testNavigation(JSONArray sidebarObjects) throws InterruptedException, JSONException { + for(int i = 0; i < sidebarObjects.length(); i++){ + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); + onData(anything()).inAdapterView(withId(R.id.drawer_list)).atPosition(i).perform(click()); + waitForPageLoaded(); + String sidebarURL = sidebarObjects.getJSONObject(i).getString("url"); + if(!currentURL.matches(sidebarURL)) throw new RuntimeException("Sidebar Menu " + (i+1) + " did not load the designated URL - " + sidebarURL); + } + } + + public void testInternalvExternalLinks(UiDevice uiDevice) throws InterruptedException { + m_mainActivity.runOnUiThread(() -> m_webView.loadUrl("https://gonative-test-web.web.app/")); + waitForPageLoaded(); + onWebView().withElement(findElement(Locator.ID, "facebook_link")).perform(webClick()); + waitForPageLoaded(); + String dFacebook = uiDevice.getCurrentPackageName(); + + if(dFacebook.contains("io.gonative.android")){ + uiDevice.pressBack(); + Thread.sleep(2000); + }else throw new RuntimeException("Facebook link opened externally"); + + onWebView().withElement(findElement(Locator.ID, "twitter_link")).perform(webClick()); + Thread.sleep(8000); + String dTwitter = uiDevice.getCurrentPackageName(); + if(dTwitter.contains("io.gonative.android")) throw new RuntimeException("Twitter opened internally."); + else { + uiDevice.pressBack(); + Thread.sleep(1000); + } + } + + public void testRefreshButton() throws InterruptedException { + onView(withId(R.id.action_refresh)).perform(click()); + waitForPageLoaded(); + } + + public void testSearchButton() throws InterruptedException { + String query = "Gonative"; + onView(withId(R.id.action_search)).perform(click()); + Thread.sleep(1000); + onView(withResourceName("search_src_text")).perform(typeText(query), pressImeActionButton()); + waitForPageLoaded(); + try{ + onWebView().withElement(findElement(Locator.ID, "search_param")).check(webMatches(getText(), equalTo(query))); + }catch (Exception exception){ + throw new RuntimeException("Search button failed to load the results with query - " + query); + } + pressBackUnconditionally(); + Thread.sleep(2000); + } + + public void m_testTabNavigation(HashMap tabMenus, ArrayList tabMenuRegexes) throws JSONException, InterruptedException { + while(!(currentURL.matches(tabMenuRegexes.get(0).pattern()))){ + m_mainActivity.runOnUiThread(() -> m_webView.loadUrl("https://gonative.io/about/")); + waitForPageLoaded(); + } + if (tabMenus.size() == 0) throw new RuntimeException("No Tab Menus Added."); + else { + for (Pattern p : tabMenuRegexes) { + if (currentURL.matches(p.pattern())) { + try { + for (String i : tabMenus.keySet()) { + for (int j = 0; j < tabMenus.get(i).length(); ) { + if (currentURL.matches(p.pattern())) { + if (isURL(tabMenus.get(i).getJSONObject(j).get("url").toString())) { + Thread.sleep(1000); + onView(withText(tabMenus.get(i).getJSONObject(j).get("label").toString())).perform(click()); + waitForPageLoaded(); + prevURL = currentURL; + pressBackUnconditionally(); + waitForPageLoaded(); + String tabURL = tabMenus.get(i).getJSONObject(j).get("url").toString(); + if (!(prevURL.matches(tabURL))) throw new RuntimeException("Tab " + (j+1) + " could not load the designated URL - " + prevURL); + j++; + prevURL = currentURL; + } else { + onView(withText(tabMenus.get(i).getJSONObject(j).get("label").toString())).perform(click()); + j++; + Thread.sleep(2000); + } + } + } + } + } catch (NoMatchingViewException | PerformException noMatchingViewException) { + throw new RuntimeException("Tab Menu not displayed in the desired regex: " + p.pattern()); + } + } else{ + throw new RuntimeException("No Tab Menus found on the current page."); + } + } + } + } + + public void testPullToRefresh() throws InterruptedException { + onView(withId(R.id.webview)).perform(swipeDown()); + waitForPageLoaded(); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a4cbca0 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/BlobDownloader.js b/app/src/main/assets/BlobDownloader.js new file mode 100644 index 0000000..2faaee9 --- /dev/null +++ b/app/src/main/assets/BlobDownloader.js @@ -0,0 +1,77 @@ +// This is used because download from native side won't have session changes. + +function gonativeDownloadBlobUrl(url) { + var req = new XMLHttpRequest(); + req.open('GET', url, true); + req.responseType = 'blob'; + + req.onload = function(event) { + var blob = req.response; + saveBlob(blob); + }; + req.send(); + + function sendMessage(message) { + if (window.webkit && window.webkit.messageHandlers && + window.webkit.messageHandlers.fileWriterSharer) { + window.webkit.messageHandlers.fileWriterSharer.postMessage(message); + } + if (window.gonative_file_writer_sharer && window.gonative_file_writer_sharer.postMessage) { + window.gonative_file_writer_sharer.postMessage(JSON.stringify(message)); + } + } + + function saveBlob(blob, filename) { + var chunkSize = 1024 * 1024; // 1mb + var index = 0; + // random string to identify this file transfer + var id = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + + function sendHeader() { + sendMessage({ + event: 'fileStart', + id: id, + size: blob.size, + type: blob.type, + name: filename + }); + } + + function sendChunk() { + if (index >= blob.size) { + return sendEnd(); + } + + var chunkToSend = blob.slice(index, index + chunkSize); + var reader = new FileReader(); + reader.readAsDataURL(chunkToSend); + reader.onloadend = function() { + sendMessage({ + event: 'fileChunk', + id: id, + data: reader.result + }); + index += chunkSize; + setTimeout(sendChunk); + }; + } + + function sendEnd() { + sendMessage({ + event: 'fileEnd', + id:id + }); + } + + sendHeader(); + gonative_run_after_storage_permissions.push(sendChunk); + } +} + +gonative_run_after_storage_permissions = []; +function gonativeGotStoragePermissions() { + while (gonative_run_after_storage_permissions.length > 0) { + var run = gonative_run_after_storage_permissions.shift(); + run(); + } +} diff --git a/app/src/main/assets/GoNativeJSBridgeLibrary.js b/app/src/main/assets/GoNativeJSBridgeLibrary.js new file mode 100644 index 0000000..0285534 --- /dev/null +++ b/app/src/main/assets/GoNativeJSBridgeLibrary.js @@ -0,0 +1,339 @@ +// this function accepts a callback function as params.callback that will be called with the command results +// if a callback is not provided it returns a promise that will resolve with the command results +function addCommandCallback(command, params, persistCallback) { + if(params?.callback || params?.callbackFunction || params?.statuscallback){ + // execute command with provided callback function + addCommand(command, params, persistCallback); + } else { + // create a temporary function and return a promise that executes command + var tempFunctionName = '_median_temp_' + Math.random().toString(36).slice(2); + if(!params) params = {}; + params.callback = tempFunctionName; + return new Promise(function(resolve, reject) { + // declare a temporary function + window[tempFunctionName] = function(data) { + resolve(data); + delete window[tempFunctionName]; + } + // execute command + addCommand(command, params); + }); + } +} + +function addCallbackFunction(callbackFunction, persistCallback){ + var callbackName; + if(typeof callbackFunction === 'string'){ + callbackName = callbackFunction; + } else { + callbackName = '_median_temp_' + Math.random().toString(36).slice(2); + window[callbackName] = function(...args) { + callbackFunction.apply(null, args); + if(!persistCallback){ // if callback is used just once + delete window[callbackName]; + } + } + } + return callbackName; +} + +function addCommand(command, params, persistCallback){ + var data = undefined; + if(params) { + var commandObject = {}; + if(params.callback && typeof params.callback === 'function'){ + params.callback = addCallbackFunction(params.callback, persistCallback); + } + if(params.callbackFunction && typeof params.callbackFunction === 'function'){ + params.callbackFunction = addCallbackFunction(params.callbackFunction, persistCallback); + } + if(params.statuscallback && typeof params.statuscallback === 'function'){ + params.statuscallback = addCallbackFunction(params.statuscallback, persistCallback); + } + commandObject.medianCommand = command; + commandObject.data = params; + data = JSON.stringify(commandObject); + } else data = command; + + JSBridge.postMessage(data); +} + +/////////////////////////////// +//// General Commands //// +/////////////////////////////// + +var median = {}; + +// backward compatibility for GoNative +var gonative = median; + +// to be modified as required +median.nativebridge = { + custom: function (params){ + addCommand("median://nativebridge/custom", params); + }, + multi: function (params){ + addCommand("median://nativebridge/multi", params); + } +}; + +median.registration = { + send: function(customData){ + var params = {customData: customData}; + addCommand("median://registration/send", params); + } +}; + +median.sidebar = { + setItems: function (params){ + addCommand("median://sidebar/setItems", params); + }, + getItems: function (params){ + return addCommandCallback("median://sidebar/getItems", params); + } +}; + +median.tabNavigation = { + selectTab: function (tabIndex){ + addCommand("median://tabs/select/" + tabIndex); + }, + deselect: function (){ + addCommand("median://tabs/deselect"); + }, + setTabs: function (tabsObject){ + var params = {tabs: tabsObject}; + addCommand("median://tabs/setTabs", params); + } +}; + +median.share = { + sharePage: function (params){ + addCommand("median://share/sharePage", params); + }, + downloadFile: function (params){ + addCommand("median://share/downloadFile", params); + }, + downloadImage: function(params){ + addCommand("median://share/downloadImage", params); + } +}; + +median.open = { + appSettings: function (){ + addCommand("median://open/app-settings"); + } +}; + +median.webview = { + clearCache: function(){ + addCommand("median://webview/clearCache"); + }, + clearCookies: function(){ + addCommand("median://webview/clearCookies"); + }, + reload: function (){ + addCommand("median://webview/reload"); + } +}; + +median.config = { + set: function(params){ + addCommand("median://config/set", params); + } +}; + +median.navigationTitles = { + set: function (parameters){ + var params = { + persist: parameters.persist, + data: parameters + }; + addCommand("median://navigationTitles/set", params); + }, + setCurrent: function (params){ + addCommand("median://navigationTitles/setCurrent", params); + }, + revert: function(){ + addCommand("median://navigationTitles/set?persist=true"); + } +}; + +median.navigationLevels = { + set: function (parameters){ + var params = { + persist: parameters.persist, + data: parameters + }; + addCommand("median://navigationLevels/set", params); + }, + setCurrent: function(params){ + addCommand("median://navigationLevels/set", params); + }, + revert: function(){ + addCommand("median://navigationLevels/set?persist=true"); + } +}; + +median.statusbar = { + set: function (params){ + addCommand("median://statusbar/set", params); + } +}; + +median.screen = { + setBrightness: function(data){ + var params = data; + if(typeof params === 'number'){ + params = {brightness: data}; + } + addCommand("median://screen/setBrightness", params); + }, + setMode: function(params) { + if (params.mode) { + addCommand("median://screen/setMode", params); + } + } +}; + +median.navigationMaxWindows = { + set: function (maxWindows, autoClose){ + var params = { + data: maxWindows, + autoClose: autoClose, + persist: true + }; + addCommand("median://navigationMaxWindows/set", params); + }, + setCurrent: function(maxWindows, autoClose){ + var params = {data: maxWindows, autoClose: autoClose}; + addCommand("median://navigationMaxWindows/set", params); + } +} + +median.window = { + open: function (urlString, mode) { + var params = {url: urlString, mode}; + addCommand("median://window/open", params); + }, + close: function () { + addCommand("median://window/close"); + } +} + +median.connectivity = { + get: function (params){ + return addCommandCallback("median://connectivity/get", params); + }, + subscribe: function (params){ + return addCommandCallback("median://connectivity/subscribe", params, true); + }, + unsubscribe: function (){ + addCommand("median://connectivity/unsubscribe"); + } +}; + +median.run = { + deviceInfo: function(){ + addCommand("median://run/median_device_info"); + } +}; + +median.deviceInfo = function(params){ + return addCommandCallback("median://run/median_device_info", params, true); +}; + +median.internalExternal = { + set: function(params){ + addCommand("median://internalExternal/set", params); + } +}; + +median.clipboard = { + set: function(params){ + addCommand("median://clipboard/set", params); + }, + get: function(params){ + return addCommandCallback("median://clipboard/get", params); + } +}; + +median.keyboard = { + info: function(params){ + return addCommandCallback("median://keyboard/info", params); + }, + listen: function(callback){ + var params = {callback}; + addCommand("median://keyboard/listen", params); + } +}; + +////////////////////////////////////// +//// Webpage Helper Functions //// +////////////////////////////////////// + +function median_match_statusbar_to_body_background_color() { + let rgb = window.getComputedStyle(document.body, null).getPropertyValue('background-color'); + let sep = rgb.indexOf(",") > -1 ? "," : " "; + rgb = rgb.substring(rgb.indexOf('(')+1).split(")")[0].split(sep).map(function(x) { return x * 1; }); + if(rgb.length === 4){ + rgb = rgb.map(function(x){ return parseInt(x * rgb[3]); }) + } + let hex = '#' + rgb[0].toString(16).padStart(2,'0') + rgb[1].toString(16).padStart(2,'0') + rgb[2].toString(16).padStart(2,'0'); + let luma = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; // per ITU-R BT.709 + if(luma > 40){ + median.statusbar.set({'style': 'dark', 'color': hex}); + } + else{ + median.statusbar.set({'style': 'light', 'color': hex}); + } +} + +function gonative_match_statusbar_to_body_background_color() { + median_match_statusbar_to_body_background_color(); +} + +/////////////////////////////// +//// Android Exclusive //// +/////////////////////////////// + +median.android = {}; + +median.android.geoLocation = { + promptLocationServices: function(){ + addCommand("median://geoLocation/promptLocationServices"); + }, + isLocationServicesEnabled: function(params) { + return addCommandCallback("median://geoLocation/isLocationServicesEnabled", params); + } +}; + +median.android.screen = { + fullscreen: function(){ + addCommand("median://screen/fullscreen"); + }, + normal: function(){ + addCommand("median://screen/normal"); + }, + keepScreenOn: function(){ + addCommand("median://screen/keepScreenOn"); + }, + keepScreenNormal: function(){ + addCommand("median://screen/keepScreenNormal"); + } +}; + +median.android.audio = { + requestFocus: function(enabled){ + var params = {enabled: enabled}; + addCommand("median://audio/requestFocus", params); + } +}; + +median.android.swipeGestures = { + enable: function() { + addCommand("median://swipeGestures/enable"); + }, + disable: function() { + addCommand("median://swipeGestures/disable"); + } +} diff --git a/app/src/main/assets/appConfig.json b/app/src/main/assets/appConfig.json new file mode 100644 index 0000000..06ace48 --- /dev/null +++ b/app/src/main/assets/appConfig.json @@ -0,0 +1,335 @@ +{ + "general": { + "screenOrientation": { + "iphone": null, + "ipad": null, + "androidPhone": null, + "androidTablet": null + }, + "userAgentRegexes": [], + "replaceStrings": [], + "nativeBridgeUrls": [], + "languages": [], + "userAgentAdd": "median", + "enableWindowOpen": true, + "forceUserAgent": "", + "publicKey": "lnooda", + "deviceRegKey": "x92rdmjynczrv92tyrydrypfxw", + "appName": "AnimeDex", + "initialUrl": "https://animedex.pp.ua/", + "iosBundleId": "co.median.ios.lnooda", + "androidPackageName": "co.median.android.lnooda", + "iosUserAgentAdd": "median", + "androidUserAgentAdd": "median", + "androidSigningKey": "upload", + "injectMedianJS": true, + "androidForceUserAgent": "", + "iosForceUserAgent": "", + "iosCustomHeaders": {}, + "androidCustomHeaders": {}, + "version": 5 + }, + "navigation": { + "tabNavigation": { + "tabSelectionConfig": [], + "tabMenus": [], + "active": false + }, + "sidebarNavigation": { + "menuSelectionConfig": { + "redirectLocations": [ + { + "regex": ".*", + "menuName": "default", + "loggedIn": true + } + ] + }, + "menus": [ + { + "active": false, + "items": [ + { + "url": "https://github.com/TechShreyash", + "label": "Made with by tech\n shreyash", + "subLinks": [] + }, + { + "url": "https://techzbots.t.me/", + "label": "(@techzbots)", + "subLinks": [] + } + ], + "name": "default" + } + ] + }, + "regexInternalExternal": { + "rules": [ + { + "mode": "external", + "label": "Non-web links", + "pagesToTrigger": "custom", + "regex": "^(?!https?://).*" + }, + { + "mode": "appbrowser", + "label": "Facebook", + "pagesToTrigger": "custom", + "regex": "https?://([-\\w]+\\.)*facebook\\.com.*" + }, + { + "mode": "appbrowser", + "label": "Twitter", + "pagesToTrigger": "custom", + "regex": "https?://([-\\w]+\\.)*twitter\\.com/.*" + }, + { + "mode": "appbrowser", + "label": "Instagram", + "pagesToTrigger": "custom", + "regex": "https?://([-\\w]+\\.)*instagram\\.com/.*" + }, + { + "mode": "appbrowser", + "label": "Google Maps", + "pagesToTrigger": "custom", + "regex": "https?://maps\\.google\\.com.*" + }, + { + "mode": "appbrowser", + "label": "Google Maps Search", + "pagesToTrigger": "custom", + "regex": "https?://([-\\w]+\\.)*google\\.com/maps/search/.*" + }, + { + "mode": "appbrowser", + "label": "LinkedIn", + "pagesToTrigger": "custom", + "regex": "https?://([-\\w]+\\.)*linkedin\\.com/.*" + }, + { + "mode": "internal", + "label": "All pages on my domain", + "pagesToTrigger": "domain", + "regex": "https?:\\/\\/([-\\w]+\\.)*pp.ua(\\/.*)?$" + }, + { + "mode": "appbrowser", + "label": "All Other Links", + "pagesToTrigger": "all", + "regex": ".*" + } + ], + "active": true + }, + "androidPullToRefresh": false, + "iosPullToRefresh": true, + "navigationTitles": { + "titles": [], + "active": true + }, + "ignorePageFinishedRegexes": [], + "actionConfig": { + "actions": [], + "actionSelection": [], + "active": true + }, + "androidShowRefreshButton": false, + "iosShowRefreshButton": false, + "deepLinkDomains": { + "domains": [], + "enableAndroidApplinks": false + }, + "navigationLevels": { + "levels": [], + "active": true + }, + "toolbarNavigation": { + "enabled": true, + "visibilityByPages": "allPages", + "visibilityByBackButton": "backButtonActive", + "regexes": [ + { + "enabled": true, + "regex": ".*" + } + ], + "items": [ + { + "system": "back", + "titleType": "defaultText", + "visibility": "allPages", + "urlRegex": [ + { + "enabled": true, + "regex": ".*" + } + ] + }, + { + "system": "refresh", + "enabled": false, + "visibility": "allPages", + "urlRegex": [ + { + "enabled": true, + "regex": ".*" + } + ] + }, + { + "system": "forward", + "enabled": false, + "titleType": "defaultText", + "visibility": "allPages", + "urlRegex": [ + { + "enabled": true, + "regex": ".*" + } + ] + } + ], + "v2": true + }, + "redirects": [], + "iosShowOfflinePage": true, + "iosConnectionOfflineTime": 10, + "androidShowOfflinePage": true, + "androidConnectionOfflineTime": 10, + "maxWindows": { + "enabled": false, + "numWindows": 5, + "autoClose": false + }, + "iosSettings": {} + }, + "styling": { + "iosLaunchScreen": { + "backgroundColor": "#FFFFFF", + "backgroundColorDark": "#000000", + "backgroundImage": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312962764_0fcwb4r07j8j8_FFFFFF_1_2x2.png", + "backgroundImageDark": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312962794_k3dj5r2qbq5q8_000000_1_2x2.png", + "centerImageDark": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/app_icon_1707312962.png", + "centerImage": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/app_icon_1707312962.png" + }, + "androidLaunchImages": { + "mdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963013_mru49z2gh2arg_180x180.png", + "hdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963039_7yjqubru19g1a_270x270.png", + "xhdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963072_za3gzyetd6ya0_360x360.png", + "xxhdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963104_3dby9pkhqx1gy_540x540.png", + "xxxhdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963139_pxb0xka9bakfj_720x720.png" + }, + "androidLaunchImagesDark": { + "mdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963162_uhek9gfcjb8cg_180x180.png", + "hdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963212_xec70t55m2jzj_270x270.png", + "xhdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963266_xurfw0b0d9kw0_360x360.png", + "xxhdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963314_b8bkdzz8hvwq6_540x540.png", + "xxxhdpi": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/1707312963380_y3djj1e3gctz8_720x720.png" + }, + "transitionInteractiveDelayMax": 0.2, + "menuAnimationDuration": 0.15, + "androidShowSplash": true, + "disableAnimations": false, + "hideWebviewAlpha": 0.5, + "iosDarkMode": "auto", + "iosTitleColor": "#0E0D08", + "iosTitleColorDark": "#ffffff", + "iosTintColor": "#0E0D08", + "iosTintColorDark": "#ffffff", + "iosSidebarTextColor": "#0E0D08", + "iosSidebarTextColorDark": "#ffffff", + "iosStatusBarBackgroundColor": "#ffffffff", + "iosStatusBarBackgroundColorDark": "#000000", + "iosEnableBlurInStatusBar": false, + "iosSidebarBackgroundColor": "#f8f8f8", + "iosSidebarBackgroundColorDark": "#202020", + "iosNavigationBarTintColor": "#f8f8f8", + "iosNavigationBarTintColorDark": "#202020", + "iosTabBarTintColor": "#f8f8f8", + "iosTabBarTintColorDark": "#000000", + "icon": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/app_icon_1707312962.png", + "iosHeaderImage": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/app_icon_1707312962.png", + "showActionBar": false, + "showNavigationBar": false, + "iosSidebarFont": "Default", + "androidHideTitleInActionBar": false, + "navigationTitleImage": false, + "iosTheme": "default", + "androidTheme": "auto", + "androidBackgroundColor": "#FFFFFF", + "androidSidebarBackgroundColor": "#FFFFFF", + "androidSidebarForegroundColor": "#1A100B", + "androidActionBarBackgroundColor": "#FFFFFF", + "androidActionBarForegroundColor": "#1A100B", + "androidPullToRefreshColor": "#1A100B", + "androidAccentColor": "#009688", + "androidSidebarSeparatorColor": "#CCCCCC", + "androidSidebarHighlightColor": "#1A100B", + "androidTabBarBackgroundColor": "#FFFFFF", + "androidTabBarTextColor": "#949494", + "androidTabBarIndicatorColor": "#1A100B", + "androidTabPressedBackgroundColor": "#CCCCCC", + "androidStatusBarBackgroundColor": "#5C5C5C", + "androidShowLogoInSideBar": true, + "androidShowAppNameInSideBar": true, + "androidSwipeNavigationBackgroundColor": "#FFFFFF", + "androidSwipeNavigationActiveColor": "#000000", + "androidSwipeNavigationInactiveColor": "#666666", + "androidActionBarBackgroundColorDark": "#333333", + "androidStatusBarBackgroundColorDark": "#333333", + "androidActionBarForegroundColorDark": "#FFFFFF", + "androidAccentColorDark": "#80cbc4", + "androidBackgroundColorDark": "#333333", + "androidSidebarForegroundColorDark": "#FFFFFF", + "androidSidebarBackgroundColorDark": "#333333", + "androidSidebarSeparatorColorDark": "#666666", + "androidSidebarHighlightColorDark": "#FFFFFF", + "androidPullToRefreshColorDark": "#FFFFFF", + "androidTabBarTextColorDark": "#FFFFFF", + "androidTabBarBackgroundColorDark": "#333333", + "androidTabBarIndicatorColorDark": "#666666", + "androidTabPressedBackgroundColorDark": "#999999", + "androidSwipeNavigationBackgroundColorDark": "#333333", + "androidSwipeNavigationActiveColorDark": "#FFFFFF", + "androidSwipeNavigationInactiveColorDark": "#666666", + "androidSplashBackgroundColor": "#FFFFFF", + "androidSplashBackgroundColorDark": "#000000", + "androidWebviewTextZoom": null, + "androidIcon": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/app_icon_1707312962.png", + "iosIcon": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/app_icon_1707312962.png", + "iosStatusBarStyle": "auto", + "iosEnableOverlayInStatusBar": false, + "androidStatusBarStyle": "auto", + "androidEnableOverlayInStatusBar": false, + "iosActivityIndicatorColor": "#808080", + "iosActivityIndicatorColorDark": "#808080", + "androidActivityIndicatorColor": "#808080", + "androidActivityIndicatorColorDark": "#808080", + "navigationTitleImageLocation": "assets/defaults/app-icon-placeholder.png", + "navigationTitleImageLocationDark": "assets/defaults/app-icon-placeholder.png", + "androidHeaderImage": "assets/defaults/app-icon-placeholder.png", + "androidHeaderImageDark": "assets/defaults/app-icon-placeholder.png", + "iosHeaderImageDark": "https://s3.amazonaws.com/gonativeio/app_files/lnooda/app_icon_1707312962.png" + }, + "permissions": { + "usesGeolocation": false, + "androidDownloadToPublicStorage": false, + "enableWebRTCamera": false, + "enableWebRTCMicrophone": false, + "iosLocationUsageDescription": "", + "iosCameraUsageDescription": "", + "iOSATTUserTrackingDescription": "", + "iOSRequestATTConsentOnLoad": false, + "iosMicrophoneUsageDescription": "" + }, + "developmentTools": { + "enableWebConsoleLogs": false + }, + "services": { + "oneSignal": { + "active": false + } + } +} \ No newline at end of file diff --git a/app/src/main/assets/custom-icons.json b/app/src/main/assets/custom-icons.json new file mode 100644 index 0000000..902f205 --- /dev/null +++ b/app/src/main/assets/custom-icons.json @@ -0,0 +1,3 @@ +{ + "gonative-icon": 59392 +} diff --git a/app/src/main/assets/fonts/custom-icons.ttf b/app/src/main/assets/fonts/custom-icons.ttf new file mode 100644 index 0000000..d86e4ae Binary files /dev/null and b/app/src/main/assets/fonts/custom-icons.ttf differ diff --git a/app/src/main/assets/offline.html b/app/src/main/assets/offline.html new file mode 100644 index 0000000..1d561f4 --- /dev/null +++ b/app/src/main/assets/offline.html @@ -0,0 +1,49 @@ + + + + Device Offline + + + +
+ + +

No internet connection
Check your connection and try again

+
+ +
+ + + \ No newline at end of file diff --git a/app/src/main/java/co/median/android/ActionManager.java b/app/src/main/java/co/median/android/ActionManager.java new file mode 100644 index 0000000..37dfeb1 --- /dev/null +++ b/app/src/main/java/co/median/android/ActionManager.java @@ -0,0 +1,639 @@ +package co.median.android; + +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.ActionBarDrawerToggle; +import androidx.appcompat.widget.LinearLayoutCompat; +import androidx.appcompat.widget.SearchView; +import androidx.core.content.ContextCompat; +import androidx.drawerlayout.widget.DrawerLayout; + +import com.google.android.material.appbar.MaterialToolbar; + +import io.gonative.android.icons.Icon; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.regex.Pattern; + +import co.median.median_core.AppConfig; + +/** + * Created by weiyin on 11/25/14. + * Copyright 2014 GoNative.io LLC + */ +public class ActionManager { + private static final String TAG = ActionManager.class.getName(); + private static final String ACTION_SHARE = "share"; + private static final String ACTION_REFRESH = "refresh"; + private static final String ACTION_SEARCH = "search"; + private static final int ACTIONBAR_ITEM_MARGIN = 132; + + private final MainActivity activity; + private final HashMapitemToUrl; + private final int action_button_size; + private final ActionBar actionBar; + private final ImageView actionBarImageTitle; + private final int colorForeground; + private final int colorBackground; + private boolean isRoot; + private String currentMenuID; + private LinearLayout header; + private LinearLayoutCompat menuContainer; + private RelativeLayout titleContainer; + private boolean isOnSearchMode = false; + private SearchView searchView; + + private int leftItemsCount = 0; + private int rightItemsCount = 0; + + private String currentUrl; + + ActionManager(MainActivity activity) { + this.activity = activity; + this.itemToUrl = new HashMap<>(); + this.action_button_size = this.activity.getResources().getInteger(R.integer.action_button_size); + this.actionBar = activity.getSupportActionBar(); + + this.actionBarImageTitle = new ImageView(activity); + this.actionBarImageTitle.setImageResource(R.drawable.ic_actionbar); + + this.colorForeground = activity.getResources().getColor(R.color.titleTextColor); + this.colorBackground = activity.getResources().getColor(R.color.colorPrimary); + } + + public void setupActionBar(boolean isRoot) { + if (actionBar == null) return; + + this.isRoot = isRoot; + AppConfig appConfig = AppConfig.getInstance(activity); + + // Change hamburger button to back arrow if window is not root + if (!isRoot) { + actionBar.setDisplayHomeAsUpEnabled(true); + Drawable backArrow = ContextCompat.getDrawable(activity, R.drawable.abc_ic_ab_back_material); + backArrow.setColorFilter(colorForeground, PorterDuff.Mode.SRC_ATOP); + actionBar.setHomeAsUpIndicator(backArrow); + } + + header = (LinearLayout) activity.getLayoutInflater().inflate(R.layout.actionbar_title, null); + // why use a custom view and not setDisplayUseLogoEnabled and setLogo? + // Because logo doesn't work! + actionBar.setDisplayShowCustomEnabled(true); + actionBar.setDisplayShowTitleEnabled(false); + actionBar.setCustomView(header); + ActionBar.LayoutParams params = (ActionBar.LayoutParams) header.getLayoutParams(); + params.width = ActionBar.LayoutParams.MATCH_PARENT; + titleContainer = header.findViewById(R.id.title_container); + + menuContainer = header.findViewById(R.id.left_menu_container); + + ViewGroup.MarginLayoutParams titleContainerParams = (ViewGroup.MarginLayoutParams) titleContainer.getLayoutParams(); + titleContainerParams.rightMargin = ACTIONBAR_ITEM_MARGIN + 8; + + MaterialToolbar toolbar = activity.findViewById(R.id.toolbar); + toolbar.setBackgroundColor(colorBackground); + } + + public void setupTitleDisplayForUrl(String url) { + if (actionBar == null || url == null) return; + AppConfig appConfig = AppConfig.getInstance(activity); + this.currentUrl = url; + + boolean urlHasNavTitle = false; + boolean urlHasActionMenu = false; + + // Check for Nav title + HashMap urlNavTitle = appConfig.getNavigationTitleForUrl(url); + if (urlNavTitle != null) { + urlHasNavTitle = true; + } + + // Check for Action Menus + ArrayList regexes = appConfig.actionRegexes; + ArrayList ids = appConfig.actionIDs; + if (regexes != null && ids != null) { + for (int i = 0; i < regexes.size(); i++) { + Pattern regex = regexes.get(i); + if (regex.matcher(url).matches()) { + urlHasActionMenu = true; + break; + } + } + } + + if (!appConfig.showActionBar && !appConfig.showNavigationMenu && !urlHasNavTitle && !urlHasActionMenu) { + actionBar.hide(); + } else { + if (urlHasNavTitle) { + boolean showImage = true; + if (urlNavTitle.containsKey("showImage")) showImage = (boolean) urlNavTitle.get("showImage"); + + if (showImage) { + // Show image title + actionBar.setDisplayOptions(0, ActionBar.DISPLAY_SHOW_TITLE); + showTitleView(actionBarImageTitle); + } else { + // Show text title + String title = activity.getTitle().toString();; + if (urlNavTitle.containsKey("title")) title = (String) urlNavTitle.get("title"); + showTextActionBarTitle(title); + } + } else { + showLogoInActionBar(appConfig.shouldShowNavigationTitleImageForUrl(currentUrl)); + } + setupActionBarDisplay(); + actionBar.show(); + } + } + + private void showLogoInActionBar(boolean show) { + if (actionBar == null) return; + if (show) { + showTitleView(actionBarImageTitle); + } else { + // Show Text + showTextActionBarTitle(activity.getTitle()); + } + } + + public void showTextActionBarTitle(CharSequence title) { + TextView textView = new TextView(activity); + textView.setText(TextUtils.isEmpty(title) ? activity.getTitle() : title); + textView.setTextSize(18); + textView.setTypeface(null, Typeface.BOLD); + textView.setMaxLines(1); + textView.setEllipsize(TextUtils.TruncateAt.END); + textView.setTextColor(colorForeground); + showTitleView(textView); + } + + public void showTitleView(View titleView) { + if (actionBar == null) return; + if (titleView == null) return; + + LinearLayout header = (LinearLayout) actionBar.getCustomView(); + + if (header == null) return; + + // Remove Title Container child views if there is any + titleContainer.removeAllViews(); + + // Remove Title View parent if there is any + if (titleView.getParent() != null) { + ((ViewGroup) titleView.getParent()).removeView(titleView); + } + + titleContainer.addView(titleView); + } + + public void checkActions(String url) { + if (this.activity == null || url == null) return; + + AppConfig appConfig = AppConfig.getInstance(this.activity); + + ArrayList regexes = appConfig.actionRegexes; + ArrayList ids = appConfig.actionIDs; + if (regexes == null || ids == null) { + setMenuID(null); + return; + } + + for (int i = 0; i < regexes.size(); i++) { + Pattern regex = regexes.get(i); + if (regex.matcher(url).matches()) { + setMenuID(ids.get(i)); + return; + } + } + + setMenuID(null); + } + + private void setMenuID(String menuID) { + boolean changed; + + if (this.currentMenuID == null) { + changed = menuID != null; + } else { + changed = menuID == null || !this.currentMenuID.equals(menuID); + } + + if (changed) { + this.currentMenuID = menuID; + this.activity.invalidateOptionsMenu(); + } + } + + public void addActions(Menu menu) { + this.itemToUrl.clear(); + this.rightItemsCount = 0; + this.leftItemsCount = 0; + + AppConfig appConfig = AppConfig.getInstance(this.activity); + if (appConfig.actions == null) return; + menuContainer.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; + + JSONArray actions = appConfig.actions.get(currentMenuID); + if (actions == null || actions.length() == 0) { + replaceLeftIcon(null); + } else { + if (actions.length() <= 2) { + for (int itemID = 0; itemID < actions.length(); itemID++) { + JSONObject entry = actions.optJSONObject(itemID); + addRightButton(appConfig, menu, itemID, entry); + } + } else { + for (int itemID = 0; itemID < actions.length(); itemID++) { + JSONObject entry = actions.optJSONObject(itemID); + if (itemID == 0) { + addLeftButton(appConfig, entry); + } else { + addRightButton(appConfig, menu, itemID, entry); + } + } + } + } + setupActionBarDisplay(); + } + + private void addLeftButton(AppConfig appConfig, JSONObject entry) { + if (entry == null) return; + + String system = AppConfig.optString(entry, "system"); + String icon = AppConfig.optString(entry, "icon"); + String url = AppConfig.optString(entry, "url"); + + if (!TextUtils.isEmpty(system)) { + if (system.equalsIgnoreCase("refresh")) { + if (TextUtils.isEmpty(icon)) { + icon = "fa-rotate-right"; + } + Button refresh = createButtonMenu(icon); + refresh.setOnClickListener(v -> this.activity.onRefresh()); + replaceLeftIcon(refresh); + } else if (system.equalsIgnoreCase("share")) { + if (TextUtils.isEmpty(icon)) { + icon = "fa-share"; + } + Button share = createButtonMenu(icon); + share.setOnClickListener(v -> this.activity.sharePage(null, null)); + replaceLeftIcon(share); + } else if (system.equalsIgnoreCase("search")) { + if (TextUtils.isEmpty(icon)) { + icon = "fa fa-search"; + } + this.searchView = createSearchView(appConfig, icon, url, null, true); + replaceLeftIcon(this.searchView); + } else { + addLeftCustomButton(icon, url); + } + } else { + addLeftCustomButton(icon, url); + } + + if (!appConfig.showNavigationMenu) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) menuContainer.getLayoutParams(); + params.leftMargin = 35; + } + + leftItemsCount++; + } + + private void addLeftCustomButton(String icon, String url) { + Button userButton = createButtonMenu(icon); + userButton.setOnClickListener(v -> this.activity.loadUrl(url)); + replaceLeftIcon(userButton); + } + + private void addRightButton(AppConfig appConfig, Menu menu, int itemID, JSONObject entry) { + if (entry == null) return; + + String system = AppConfig.optString(entry, "system"); + String label = AppConfig.optString(entry, "label"); + String icon = AppConfig.optString(entry, "icon"); + String url = AppConfig.optString(entry, "url"); + + if (!TextUtils.isEmpty(system)) { + if (system.equalsIgnoreCase("refresh")) { + + if (TextUtils.isEmpty(icon)) { + icon = "fa-rotate-right"; + } + + Drawable refreshIcon = new Icon(activity, icon, action_button_size, colorForeground).getDrawable(); + + String menuLabel = !TextUtils.isEmpty(label) ? label : "Refresh"; + + MenuItem menuItem = menu.add(Menu.NONE, itemID, Menu.NONE, menuLabel) + .setIcon(refreshIcon) + .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM); + + itemToUrl.put(menuItem, ACTION_REFRESH); + } else if (system.equalsIgnoreCase("share")) { + + if (TextUtils.isEmpty(icon)) { + icon = "fa-share"; + } + + Drawable refreshIcon = new Icon(activity, icon, action_button_size, colorForeground).getDrawable();; + + String menuLabel = !TextUtils.isEmpty(label) ? label : "Share"; + + MenuItem menuItem = menu.add(Menu.NONE, itemID, Menu.NONE, menuLabel) + .setIcon(refreshIcon) + .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM); + + itemToUrl.put(menuItem, ACTION_SHARE); + + } else if (system.equalsIgnoreCase("search")) { + + if (TextUtils.isEmpty(icon)) { + icon = "fa fa-search"; + } + + String menuLabel = !TextUtils.isEmpty(label) ? label : "Search"; + + MenuItem menuItem = menu.add(Menu.NONE, itemID, Menu.NONE, menuLabel) + .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM); + + this.searchView = createSearchView(appConfig, icon, url, menuItem, false); + menuItem.setActionView(searchView); + + itemToUrl.put(menuItem, ACTION_SEARCH); + } else { + addRightCustomButton(menu, itemID, label, icon, url); + } + } else { + addRightCustomButton(menu, itemID, label, icon, url); + } + + rightItemsCount++; + } + + private void addRightCustomButton(Menu menu, int itemID, String label, String icon, String url) { + Drawable iconDrawable = null; + if (icon != null) { + iconDrawable = new Icon(activity, icon, action_button_size, colorForeground).getDrawable(); + } + MenuItem menuItem = menu.add(Menu.NONE, itemID, Menu.NONE, label) + .setIcon(iconDrawable) + .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM); + + if (url != null) { + this.itemToUrl.put(menuItem, url); + } + } + + private void replaceLeftIcon(View view) { + if (menuContainer == null) return; + menuContainer.removeAllViews(); + if (view != null) { + menuContainer.addView(view); + menuContainer.setVisibility(View.VISIBLE); + } else { + menuContainer.setVisibility(View.GONE); + } + } + + private Button createButtonMenu(String iconString) { + Drawable icon = new Icon(activity, iconString, action_button_size, colorForeground).getDrawable(); + icon.setBounds(0, 0, 50, 50); + LinearLayout tempView = (LinearLayout) LayoutInflater.from(activity).inflate(R.layout.button_menu, null); + Button button = tempView.findViewById(R.id.menu_button); + tempView.removeView(button); + button.setCompoundDrawables(icon, null, null, null); + return button; + } + + private SearchView createSearchView(AppConfig appConfig, String icon, String url, MenuItem menuItem, boolean forLeftSide) { + SearchView searchView = new SearchView(activity); + + // Set layout Params to WRAP_CONTENT + ViewGroup.LayoutParams searchViewParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + searchView.setLayoutParams(searchViewParams); + + // Left Drawer Instance + DrawerLayout drawerLayout = activity.getDrawerLayout(); + ActionBarDrawerToggle drawerToggle = activity.getDrawerToggle(); + + // search item in action bar + SearchView.SearchAutoComplete searchAutoComplete = searchView.findViewById(androidx.appcompat.R.id.search_src_text); + if (searchAutoComplete != null) { + searchAutoComplete.setTextColor(colorForeground); + int hintColor = colorForeground; + hintColor = Color.argb(192, Color.red(hintColor), Color.green(hintColor), + Color.blue(hintColor)); + searchAutoComplete.setHintTextColor(hintColor); + } + + searchView.setOnSearchClickListener(view -> { + searchViewParams.width = ActionBar.LayoutParams.MATCH_PARENT; + + // Need to check this otherwise the app will crash + if (!activity.isNotRoot() && appConfig.showNavigationMenu) { + drawerToggle.setDrawerIndicatorEnabled(false); + drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); + + drawerToggle.setDrawerIndicatorEnabled(false); + actionBar.setDisplayShowHomeEnabled(true); + } else if (!activity.isNotRoot()) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + isOnSearchMode = true; + }); + + searchView.setOnCloseListener(() -> { + if (forLeftSide) { + titleContainer.setVisibility(View.VISIBLE); + } else { + header.setVisibility(View.VISIBLE); + activity.invalidateOptionsMenu(); + } + searchViewParams.width = ViewGroup.LayoutParams.WRAP_CONTENT; + + activity.setMenuItemsVisible(true); + if (!activity.isNotRoot() && appConfig.showNavigationMenu) { + drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); + actionBar.setDisplayShowHomeEnabled(false); + drawerToggle.setDrawerIndicatorEnabled(true); + } else if (!activity.isNotRoot()) { + actionBar.setDisplayHomeAsUpEnabled(false); + } + return false; + }); + + // listener to process query + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + if (!searchView.isIconified()) { + searchView.setIconified(true); + } + try { + String q = URLEncoder.encode(query, "UTF-8"); + activity.loadUrl(url + q); + } catch (UnsupportedEncodingException e) { + return true; + } + + return true; + } + + @Override + public boolean onQueryTextChange(String newText) { + // do nothing + return true; + } + }); + + // listener to collapse action view when soft keyboard is closed + searchView.setOnQueryTextFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (!hasFocus) { + if (!searchView.isIconified()) { + searchView.setIconified(true); + } + } + } + }); + + // Search view button icon and color + ImageView searchIcon = searchView.findViewById(androidx.appcompat.R.id.search_button); + if (searchIcon != null) { + icon = !TextUtils.isEmpty(icon) ? icon : "fa fa-search"; + Drawable searchButtonNewIcon = new Icon(activity, icon, action_button_size, colorForeground).getDrawable(); + searchIcon.setImageDrawable(searchButtonNewIcon); + searchIcon.setColorFilter(colorForeground); + + // Handling a bug when SearchView is expanded and setting other menu + // visibility to false, SearchView would trigger unnecessary onClose event + // Solution is to hide other menus first before expanding + searchIcon.setOnClickListener(v -> { + if (forLeftSide) { + activity.setMenuItemsVisible(false); + titleContainer.setVisibility(View.GONE); + } else { + header.setVisibility(View.GONE); + activity.setMenuItemsVisible(false, menuItem); + } + // Expand SearchView, simulates onSearchViewClicked event + searchView.setIconified(false); + }); + } + + //Search view close button foreground color + ImageView closeButtonImage = searchView.findViewById(androidx.appcompat.R.id.search_close_btn); + if (closeButtonImage != null) { + closeButtonImage.setColorFilter(colorForeground); + } + + return searchView; + } + + // Count left and right actionbar buttons to calculate side margins + public void setupActionBarDisplay() { + + if (actionBar == null) return; + + AppConfig appConfig = AppConfig.getInstance(activity); + + // Add to temporary fields so actual items count would not be affected + int tempLeftItemsCount = leftItemsCount; + + // Limit right menu count to three for margin + int tempRightItemsCount = Math.min(rightItemsCount, 3); + + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) titleContainer.getLayoutParams(); + + // Reset the margins + params.rightMargin = 0; + params.leftMargin = 0; + + if (isRoot) { + if (appConfig.showNavigationMenu) { + tempLeftItemsCount++; + } + + if (tempLeftItemsCount > tempRightItemsCount) { + int margin = tempLeftItemsCount - tempRightItemsCount; + params.rightMargin = ACTIONBAR_ITEM_MARGIN * margin; + } else { + int margin = tempRightItemsCount - tempLeftItemsCount; + params.leftMargin = ACTIONBAR_ITEM_MARGIN * margin; + } + } else { + tempLeftItemsCount++; + + if (tempLeftItemsCount > tempRightItemsCount) { + int margin = tempLeftItemsCount - tempRightItemsCount; + params.rightMargin = ACTIONBAR_ITEM_MARGIN * margin; + } else { + int margin = tempRightItemsCount - tempLeftItemsCount; + params.leftMargin = ACTIONBAR_ITEM_MARGIN * margin; + } + } + } + + public boolean isOnSearchMode() { + return isOnSearchMode; + } + + public void setOnSearchMode(boolean onSearchMode) { + isOnSearchMode = onSearchMode; + } + + public void closeSearchView() { + if (searchView == null) return; + + if (!searchView.isIconified()) { + searchView.setIconified(true); + } + } + + public boolean onOptionsItemSelected(MenuItem item) { + if (activity.getCurrentFocus() instanceof SearchView.SearchAutoComplete) { + activity.getCurrentFocus().clearFocus(); + } + String url = this.itemToUrl.get(item); + if (url != null) { + switch (url) { + case ACTION_SHARE: + this.activity.sharePage(null, null); + return true; + case ACTION_REFRESH: + this.activity.onRefresh(); + return true; + case ACTION_SEARCH: + // Ignore + return true; + } + + this.activity.loadUrl(url); + return true; + } else { + return false; + } + } +} diff --git a/app/src/main/java/co/median/android/AppLinksActivity.java b/app/src/main/java/co/median/android/AppLinksActivity.java new file mode 100644 index 0000000..a3eba92 --- /dev/null +++ b/app/src/main/java/co/median/android/AppLinksActivity.java @@ -0,0 +1,29 @@ +package co.median.android; + +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +public class AppLinksActivity extends AppCompatActivity { + + public static final String LAUNCH_SOURCE_APP_LINKS = "app_links"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + launchApp(); + } + + private void launchApp() { + Intent intent = new Intent(this, MainActivity.class); + if (getIntent().getData() != null) { + intent.setData(getIntent().getData()); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra("source", LAUNCH_SOURCE_APP_LINKS); + } + startActivity(intent); + finish(); + } +} diff --git a/app/src/main/java/co/median/android/AppUpgradeReceiver.java b/app/src/main/java/co/median/android/AppUpgradeReceiver.java new file mode 100644 index 0000000..bab4c75 --- /dev/null +++ b/app/src/main/java/co/median/android/AppUpgradeReceiver.java @@ -0,0 +1,21 @@ +package co.median.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import co.median.median_core.AppConfig; + +public class AppUpgradeReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + // This will be executed when a new version of the app has been installed over an existing one + // Does not work in debug mode. + if (context == null || intent == null) return; + if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())) { + AppConfig appConfig = AppConfig.getInstance(context); + appConfig.deletePersistentConfigFiles(); + } + } +} diff --git a/app/src/main/java/co/median/android/AudioUtils.java b/app/src/main/java/co/median/android/AudioUtils.java new file mode 100644 index 0000000..42359e4 --- /dev/null +++ b/app/src/main/java/co/median/android/AudioUtils.java @@ -0,0 +1,183 @@ +package co.median.android; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.util.Log; + +public class AudioUtils { + private static final String TAG = AudioUtils.class.getName(); + private static AudioFocusRequest initialFocusRequest; + private static AudioFocusRequest focusRequest; + private static AudioManager.OnAudioFocusChangeListener initialAudioFocusChangeListener; + private static AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; + + /** + * @param mode - Accepts int for speaker mode: + * 0 - phone speaker (default) + * 1 - headset / wired device + * 2 - bluetooth + */ + public static void setUpAudioDevice(MainActivity mainActivity, int mode) { + AudioManager mAudioManager = (AudioManager) mainActivity.getSystemService(Context.AUDIO_SERVICE); + if (mode == 2) { + // bluetooth device + mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + mAudioManager.startBluetoothSco(); + mAudioManager.setBluetoothScoOn(true); + } else if (mode == 1) { + // wired device + mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + mAudioManager.stopBluetoothSco(); + mAudioManager.setBluetoothScoOn(false); + mAudioManager.setSpeakerphoneOn(false); + } else { + // phone speaker + mAudioManager.setMode(AudioManager.MODE_NORMAL); + mAudioManager.stopBluetoothSco(); + mAudioManager.setBluetoothScoOn(false); + mAudioManager.setSpeakerphoneOn(true); + } + } + + public static void reconnectToBluetooth(MainActivity mainActivity, AudioManager audioManager) { + if (audioManager.isBluetoothScoAvailableOffCall() && !audioManager.isBluetoothScoOn()) { + Log.d(TAG, "Resetting audio to bluetooth device"); + setUpAudioDevice(mainActivity, 2); + } + } + + /** + * Listen to the first AUDIOFOCUS_GAIN before taking the audio input/output priority through requestAudioFocus() + * + * @param mainActivity + */ + public static void initAudioFocusListener(MainActivity mainActivity) { + int result; + final Object focusLock = new Object(); + + AudioManager audioManager = (AudioManager) mainActivity.getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + Log.w(TAG, "AudioManager is null. Aborting initAudioFocusListener()"); + } + + initialAudioFocusChangeListener = focusChange -> { + if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + synchronized (focusLock) { + Log.d(TAG, "AudioFocusListener GAINED. Try to request audio focus"); + requestAudioFocus(mainActivity); + abandonFocusRequest(mainActivity); + } + } + }; + + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { + result = audioManager.requestAudioFocus(initialAudioFocusChangeListener, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK); + } else { + AudioAttributes playbackAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build(); + initialFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes(playbackAttributes) + .setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(initialAudioFocusChangeListener) + .build(); + result = audioManager.requestAudioFocus(initialFocusRequest); + } + + synchronized (focusLock) { + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.d(TAG, "AudioFocusListener REQUEST GRANTED"); + } + } + } + + /** + * Prioritizes the bluetooth device if available. + * Reconnects the bluetooth device when the audio focus is lost as a workaround for aborted connections + * due to AudioRecord.AUDIO_INPUT_FLAG_FAST denial. + * + * @param mainActivity + */ + public static void requestAudioFocus(MainActivity mainActivity) { + int result; + final Object focusLock = new Object(); + + AudioManager audioManager = (AudioManager) mainActivity.getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + Log.w(TAG, "AudioManager is null. Aborting requestAudioFocus()"); + } + audioFocusChangeListener = focusChange -> { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_GAIN: + synchronized (focusLock) { + Log.d(TAG, "AudioFocus GAINED. Try to connect bluetooth device"); + reconnectToBluetooth(mainActivity, audioManager); + } + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + case AudioManager.AUDIOFOCUS_LOSS: + synchronized (focusLock) { + Log.d(TAG, "AudioFocus LOST. Try to reconnect bluetooth device"); + reconnectToBluetooth(mainActivity, audioManager); + } + break; + } + }; + + abandonFocusRequest(mainActivity); + + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { + result = audioManager.requestAudioFocus(audioFocusChangeListener, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK); + } else { + AudioAttributes playbackAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build(); + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes(playbackAttributes) + .setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build(); + result = audioManager.requestAudioFocus(focusRequest); + } + + synchronized (focusLock) { + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.d(TAG, "AudioFocus REQUEST GRANTED"); + reconnectToBluetooth(mainActivity, audioManager); + } + } + } + + public static void abandonFocusRequest(MainActivity mainActivity) { + AudioManager audioManager = (AudioManager) mainActivity.getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + Log.w(TAG, "AudioManager is null. Aborting abandonFocusRequest()"); + } + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { + if (initialAudioFocusChangeListener != null) { + audioManager.abandonAudioFocus(initialAudioFocusChangeListener); + initialAudioFocusChangeListener = null; + } + if (audioFocusChangeListener != null) { + audioManager.abandonAudioFocus(audioFocusChangeListener); + audioFocusChangeListener = null; + } + } else { + if (initialFocusRequest != null) { + audioManager.abandonAudioFocusRequest(initialFocusRequest); + initialFocusRequest = null; + } + if (focusRequest != null) { + audioManager.abandonAudioFocusRequest(focusRequest); + focusRequest = null; + } + } + } +} diff --git a/app/src/main/java/co/median/android/ConfigPreferences.java b/app/src/main/java/co/median/android/ConfigPreferences.java new file mode 100644 index 0000000..b9e03b9 --- /dev/null +++ b/app/src/main/java/co/median/android/ConfigPreferences.java @@ -0,0 +1,38 @@ +package co.median.android; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.text.TextUtils; + +public class ConfigPreferences { + private static final String APP_THEME_KEY = "io.gonative.android.appTheme"; + + private Context context; + private SharedPreferences sharedPreferences; + + public ConfigPreferences(Context context) { + this.context = context; + } + + private SharedPreferences getSharedPreferences() { + if (this.sharedPreferences == null) { + this.sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this.context); + } + return this.sharedPreferences; + } + + public String getAppTheme() { + SharedPreferences preferences = getSharedPreferences(); + return preferences.getString(APP_THEME_KEY, null); + } + + public void setAppTheme(String appTheme) { + SharedPreferences preferences = getSharedPreferences(); + if (TextUtils.isEmpty(appTheme)) { + preferences.edit().remove(APP_THEME_KEY).commit(); + } else { + preferences.edit().putString(APP_THEME_KEY, appTheme).commit(); + } + } +} diff --git a/app/src/main/java/co/median/android/ConfigUpdater.java b/app/src/main/java/co/median/android/ConfigUpdater.java new file mode 100644 index 0000000..bd721e9 --- /dev/null +++ b/app/src/main/java/co/median/android/ConfigUpdater.java @@ -0,0 +1,76 @@ +package co.median.android; + +import android.content.Context; +import android.os.AsyncTask; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.OutputStreamWriter; +import java.lang.ref.WeakReference; +import java.net.HttpURLConnection; +import java.net.URL; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; + +/** + * Created by weiyin on 8/8/14. + */ +public class ConfigUpdater { + private static final String TAG = ConfigUpdater.class.getName(); + + private Context context; + + ConfigUpdater(Context context) { + this.context = context; + } + + public void registerEvent() { + if (AppConfig.getInstance(context).disableEventRecorder) return; + + new EventTask(context).execute(); + } + + private static class EventTask extends AsyncTask { + WeakReference contextReference; + + EventTask(Context context) { + this.contextReference = new WeakReference<>(context); + } + + @Override + protected Void doInBackground(Void... params) { + Context context = contextReference.get(); + if (context == null) return null; + + JSONObject json = new JSONObject(Installation.getInfo(context)); + + try { + json.put("event", "launch"); + } catch (JSONException e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + return null; + } + + try { + URL url = new URL("https://events.gonative.io/api/events/new"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + connection.setDoInput(false); // we do not care about response + OutputStreamWriter writer = new OutputStreamWriter(connection.getOutputStream(), "UTF-8"); + writer.write(json.toString()); + writer.close(); + connection.connect(); + connection.getResponseCode(); + connection.disconnect(); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + + return null; + } + } +} diff --git a/app/src/main/java/co/median/android/CustomHeaders.java b/app/src/main/java/co/median/android/CustomHeaders.java new file mode 100644 index 0000000..db835bc --- /dev/null +++ b/app/src/main/java/co/median/android/CustomHeaders.java @@ -0,0 +1,69 @@ +package co.median.android; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.provider.Settings; +import android.util.Base64; + +import java.io.UnsupportedEncodingException; +import java.util.HashMap; +import java.util.Map; + +import co.median.median_core.AppConfig; + +/** + * Created by weiyin on 5/1/17. + */ + +public class CustomHeaders { + public static Map getCustomHeaders(Context context) { + AppConfig appConfig = AppConfig.getInstance(context); + if (appConfig.customHeaders == null) return null; + + HashMap result = new HashMap<>(); + for (Map.Entry entry : appConfig.customHeaders.entrySet()) { + String key = entry.getKey(); + String val; + try { + val = interpolateValues(context, entry.getValue()); + } catch (UnsupportedEncodingException e) { + val = null; + } + + if (key != null & val != null) { + result.put(key, val); + } + } + + return result; + } + + private static String interpolateValues(Context context, String value) throws UnsupportedEncodingException { + if (value == null) return null; + + if (value.contains("%DEVICEID%")) { + @SuppressLint("HardwareIds") + String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + if (androidId == null) androidId = ""; + value = value.replace("%DEVICEID%", androidId); + } + + if (value.contains("%DEVICENAME64%")) { + // base 64 encoded name + String manufacturer = Build.MANUFACTURER; + String model = Build.MODEL; + String name; + if (model.startsWith(manufacturer)) { + name = model; + } else { + name = manufacturer + " " + model; + } + + String name64 = Base64.encodeToString(name.getBytes("UTF-8"), Base64.NO_WRAP); + value = value.replace("%DEVICENAME64%", name64); + } + + return value; + } +} diff --git a/app/src/main/java/co/median/android/DownloadService.java b/app/src/main/java/co/median/android/DownloadService.java new file mode 100644 index 0000000..fab50b3 --- /dev/null +++ b/app/src/main/java/co/median/android/DownloadService.java @@ -0,0 +1,316 @@ +package co.median.android; + +import android.app.Service; +import android.content.ActivityNotFoundException; +import android.content.ContentResolver; +import android.content.Intent; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.Environment; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; +import co.median.median_core.LeanUtils; + +public class DownloadService extends Service { + + private static final String TAG = "DownloadService"; + private static final String EXTRA_DOWNLOAD_ID = "download_id"; + private static final String ACTION_CANCEL_DOWNLOAD = "action_cancel_download"; + private static final int BUFFER_SIZE = 4096; + private static final int timeout = 5; // in seconds + private final Handler handler = new Handler(Looper.getMainLooper()); + + private final Map downloadTasks = new HashMap<>(); + private int downloadId = 0; + private String userAgent; + + @Override + public void onCreate() { + super.onCreate(); + AppConfig appConfig = AppConfig.getInstance(this); + this.userAgent = appConfig.userAgent; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent.getAction().equals(ACTION_CANCEL_DOWNLOAD)) { + int id = intent.getIntExtra(EXTRA_DOWNLOAD_ID, 0); + cancelDownload(id); + } + return START_NOT_STICKY; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return new DownloadBinder(); + } + + public class DownloadBinder extends Binder { + public DownloadService getService() { + return DownloadService.this; + } + } + + public void startDownload(String url, String filename, String mimetype, boolean shouldSaveToGallery, boolean open, FileDownloader.DownloadLocation location) { + DownloadTask downloadTask = new DownloadTask(url, filename, mimetype, shouldSaveToGallery, open, location); + downloadTasks.put(downloadTask.getId(), downloadTask); + downloadTask.startDownload(); + } + + public void cancelDownload(int downloadId) { + DownloadTask downloadTask = downloadTasks.get(downloadId); + if (downloadTask != null && downloadTask.isDownloading()) { + downloadTask.cancelDownload(); + } + } + + public void handleDownloadUri(FileDownloader.DownloadLocation location, Uri uri, String mimeType, boolean shouldSaveToGallery, boolean open, String filename) { + if (uri == null) return; + if (location == FileDownloader.DownloadLocation.PUBLIC_DOWNLOADS) { + + if (shouldSaveToGallery) { + addFileToGallery(uri); + } + + if (open) { + viewFile(uri, mimeType); + } else { + handler.post(() -> { + if (shouldSaveToGallery) { + Toast.makeText(this, R.string.file_download_finished_gallery, Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, String.format(this.getString(R.string.file_download_finished_with_name), filename), Toast.LENGTH_SHORT).show(); + } + }); + } + } else { + if (open) { + viewFile(uri, mimeType); + } else { + handler.post(() -> Toast.makeText(this, String.format(this.getString(R.string.file_download_finished_with_name), filename), Toast.LENGTH_SHORT).show()); + } + } + } + + private void viewFile(Uri uri, String mimeType) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, mimeType); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } catch (ActivityNotFoundException e) { + String message = getResources().getString(R.string.file_handler_not_found); + handler.post(() -> { + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + }); + } catch (Exception ex) { + GNLog.getInstance().logError(TAG, "viewFile: Exception:", ex); + } + } + + private void addFileToGallery(Uri uri) { + Log.d(TAG, "addFileToGallery: Adding to Albums . . ."); + Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + mediaScanIntent.setData(uri); + sendBroadcast(mediaScanIntent); + } + + private class DownloadTask { + private final int id; + private final String url; + private boolean isDownloading; + private HttpURLConnection connection; + private InputStream inputStream; + private FileOutputStream outputStream; + private File outputFile = null; + private Uri downloadUri; + private String filename; + private String extension; + private String mimetype; + private boolean saveToGallery; + private boolean openOnFinish; + private final FileDownloader.DownloadLocation location; + + public DownloadTask(String url, String filename, String mimetype, boolean saveToGallery, boolean open, FileDownloader.DownloadLocation location) { + this.id = downloadId++; + this.url = url; + this.filename = filename; + this.mimetype = mimetype; + this.isDownloading = false; + this.saveToGallery = saveToGallery; + this.openOnFinish = open; + this.location = location; + } + + public int getId() { + return id; + } + + public boolean isDownloading() { + return isDownloading; + } + + public void startDownload() { + Log.d(TAG, "startDownload: Starting download"); + isDownloading = true; + AtomicReference finalFilename = new AtomicReference<>(""); + new Thread(() -> { + Log.d(TAG, "startDownload: Thread started"); + try { + URL downloadUrl = new URL(url); + connection = (HttpURLConnection) downloadUrl.openConnection(); + connection.setInstanceFollowRedirects(true); + connection.setRequestProperty("User-Agent", userAgent); + connection.setConnectTimeout(timeout * 1000); + connection.connect(); + + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + GNLog.getInstance().logError(TAG, "Server returned HTTP " + connection.getResponseCode() + + " " + connection.getResponseMessage()); + isDownloading = false; + return; + } + + double fileSizeInMB = connection.getContentLength() / 1048576.0; + Log.d(TAG, "startDownload: File size in MB: " + fileSizeInMB); + + if (connection.getHeaderField("Content-Type") != null) + mimetype = connection.getHeaderField("Content-Type"); + + if (!TextUtils.isEmpty(filename)) { + extension = FileDownloader.getFilenameExtension(filename); + if (TextUtils.isEmpty(extension)) { + extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimetype); + } else if (Objects.equals(filename, extension)) { + filename = "download"; + } else { + filename = filename.substring(0, filename.length() - (extension.length() + 1)); + mimetype = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + } + } else { + // guess file name and extension + String guessedName = LeanUtils.guessFileName(url, + connection.getHeaderField("Content-Disposition"), + mimetype); + int pos = guessedName.lastIndexOf('.'); + + if (pos == -1) { + filename = guessedName; + extension = ""; + } else if (pos == 0) { + filename = "download"; + extension = guessedName.substring(1); + } else { + filename = guessedName.substring(0, pos); + extension = guessedName.substring(pos + 1); + } + + if (!TextUtils.isEmpty(extension)) { + // Update mimetype based on final filename extension + mimetype = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + } + } + + if (location == FileDownloader.DownloadLocation.PUBLIC_DOWNLOADS) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + ContentResolver contentResolver = getApplicationContext().getContentResolver(); + if (saveToGallery && mimetype.contains("image")) { + downloadUri = FileDownloader.createExternalFileUri(contentResolver, filename, mimetype, Environment.DIRECTORY_PICTURES); + } else { + downloadUri = FileDownloader.createExternalFileUri(contentResolver, filename, mimetype, Environment.DIRECTORY_DOWNLOADS); + saveToGallery = false; + } + if (downloadUri != null) { + finalFilename.set(FileDownloader.getFileNameFromUri(downloadUri, contentResolver)); + outputStream = (FileOutputStream) contentResolver.openOutputStream(downloadUri); + } else { + isDownloading = false; + handler.post(() -> { + Toast.makeText(DownloadService.this, getString(R.string.file_download_error), Toast.LENGTH_SHORT).show(); + }); + GNLog.getInstance().logError(TAG, "Error creating file - " + + "filename: " + filename + ", " + + "mimetype: " + mimetype); + return; + } + } else { + if (saveToGallery) { + outputFile = FileDownloader.createOutputFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), filename, extension); + } else { + outputFile = FileDownloader.createOutputFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), filename, extension); + } + finalFilename.set(outputFile.getName()); + outputStream = new FileOutputStream(outputFile); + } + } else { + this.openOnFinish = true; + outputFile = FileDownloader.createOutputFile(getFilesDir(), filename, extension); + finalFilename.set(outputFile.getName()); + outputStream = new FileOutputStream(outputFile); + } + int fileLength = connection.getContentLength(); + inputStream = connection.getInputStream(); + + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + int bytesDownloaded = 0; + + while ((bytesRead = inputStream.read(buffer)) != -1 && isDownloading) { + outputStream.write(buffer, 0, bytesRead); + bytesDownloaded += bytesRead; + int progress = (int) (bytesDownloaded * 100 / fileLength); + Log.d(TAG, "startDownload: Download progress: " + progress); + } + if (!isDownloading && outputFile != null) { + outputFile.delete(); + outputFile = null; + } + } catch (IOException e) { + GNLog.getInstance().logError(TAG, "startDownload: ", e); + } finally { + try { + if (inputStream != null) inputStream.close(); + if (outputStream != null) outputStream.close(); + if (connection != null) connection.disconnect(); + } catch (IOException e) { + GNLog.getInstance().logError(TAG, "startDownload: ", e); + } + isDownloading = false; + if (downloadUri == null && outputFile != null) { + downloadUri = FileProvider.getUriForFile(DownloadService.this, DownloadService.this.getApplicationContext().getPackageName() + ".fileprovider", outputFile); + } + handleDownloadUri(location, downloadUri, mimetype, saveToGallery, openOnFinish, finalFilename.get()); + } + }).start(); + } + + public void cancelDownload() { + isDownloading = false; + Toast.makeText(DownloadService.this, getString(R.string.download_canceled) + " " + filename, Toast.LENGTH_SHORT).show(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/co/median/android/FileDownloader.java b/app/src/main/java/co/median/android/FileDownloader.java new file mode 100644 index 0000000..e27d3cf --- /dev/null +++ b/app/src/main/java/co/median/android/FileDownloader.java @@ -0,0 +1,393 @@ +package co.median.android; + +import android.Manifest; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.IBinder; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.DownloadListener; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.core.content.ContextCompat; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import co.median.median_core.AppConfig; +import co.median.median_core.LeanUtils; + +/** + * Created by weiyin on 6/24/14. + */ +public class FileDownloader implements DownloadListener { + public enum DownloadLocation { + PUBLIC_DOWNLOADS, PRIVATE_INTERNAL + } + + private static final String TAG = FileDownloader.class.getName(); + private final MainActivity context; + private final DownloadLocation defaultDownloadLocation; + private final ActivityResultLauncher requestPermissionLauncher; + private UrlNavigation urlNavigation; + private String lastDownloadedUrl; + private DownloadService downloadService; + private boolean isBound = false; + private PreDownloadInfo preDownloadInfo; + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + DownloadService.DownloadBinder binder = (DownloadService.DownloadBinder) iBinder; + downloadService = binder.getService(); + isBound = true; + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + downloadService = null; + isBound = false; + } + }; + + FileDownloader(MainActivity context) { + this.context = context; + + AppConfig appConfig = AppConfig.getInstance(this.context); + if (appConfig.downloadToPublicStorage) { + this.defaultDownloadLocation = DownloadLocation.PUBLIC_DOWNLOADS; + } else { + this.defaultDownloadLocation = DownloadLocation.PRIVATE_INTERNAL; + } + + Intent intent = new Intent(context, DownloadService.class); + context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); + + // initialize request permission launcher + requestPermissionLauncher = context.registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), isGranted -> { + + if (isGranted.containsKey(Manifest.permission.WRITE_EXTERNAL_STORAGE) && Boolean.FALSE.equals(isGranted.get(Manifest.permission.WRITE_EXTERNAL_STORAGE))) { + Toast.makeText(context, "Unable to save download, storage permission denied", Toast.LENGTH_SHORT).show(); + return; + } + + if (preDownloadInfo != null && isBound) { + if (preDownloadInfo.isBlob) { + context.getFileWriterSharer().downloadBlobUrl(preDownloadInfo.url, preDownloadInfo.filename, preDownloadInfo.open); + } else { + downloadService.startDownload(preDownloadInfo.url, preDownloadInfo.filename, preDownloadInfo.mimetype, preDownloadInfo.shouldSaveToGallery, preDownloadInfo.open, defaultDownloadLocation); + } + preDownloadInfo = null; + } + }); + } + + @Override + public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength) { + if (urlNavigation != null) { + urlNavigation.onDownloadStart(); + } + + if (context != null) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + context.showWebview(); + } + }); + } + + // get filename from content disposition + String guessFilename = null; + if (!TextUtils.isEmpty(contentDisposition)) { + guessFilename = LeanUtils.guessFileName(url, contentDisposition, mimetype); + } + + if (url.startsWith("blob:") && context != null) { + + boolean openAfterDownload = defaultDownloadLocation == DownloadLocation.PRIVATE_INTERNAL; + + if (requestRequiredPermission(new PreDownloadInfo(url, guessFilename, true, openAfterDownload))) { + return; + } + + context.getFileWriterSharer().downloadBlobUrl(url, guessFilename, openAfterDownload); + return; + } + + lastDownloadedUrl = url; + + // try to guess mimetype + if (mimetype == null || mimetype.equalsIgnoreCase("application/force-download") || + mimetype.equalsIgnoreCase("application/octet-stream")) { + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String extension = MimeTypeMap.getFileExtensionFromUrl(url); + if (extension != null && !extension.isEmpty()) { + String guessedMimeType = mimeTypeMap.getMimeTypeFromExtension(extension); + if (guessedMimeType != null) { + mimetype = guessedMimeType; + } + } + } + + startDownload(url, guessFilename, mimetype, false, false); + } + + public void downloadFile(String url, String filename, boolean shouldSaveToGallery, boolean open) { + if (TextUtils.isEmpty(url)) { + Log.d(TAG, "downloadFile: Url empty!"); + return; + } + + if (url.startsWith("blob:") && context != null) { + + if (defaultDownloadLocation == DownloadLocation.PRIVATE_INTERNAL) { + open = true; + } + + if (requestRequiredPermission(new PreDownloadInfo(url, filename, true, open))) { + return; + } + context.getFileWriterSharer().downloadBlobUrl(url, filename, open); + return; + } + + String mimetype = "*/*"; + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String extension = MimeTypeMap.getFileExtensionFromUrl(url); + if (extension != null && !extension.isEmpty()) { + String guessedMimeType = mimeTypeMap.getMimeTypeFromExtension(extension); + if (guessedMimeType != null) { + mimetype = guessedMimeType; + } + } + + startDownload(url, filename, mimetype, shouldSaveToGallery, open); + } + + private void startDownload(String downloadUrl, String filename, String mimetype, boolean shouldSaveToGallery, boolean open) { + if (isBound) { + if (requestRequiredPermission(new PreDownloadInfo(downloadUrl, filename, mimetype, shouldSaveToGallery, open, false))) return; + downloadService.startDownload(downloadUrl, filename, mimetype, shouldSaveToGallery, open, defaultDownloadLocation); + } + } + + // Requests required permission depending on device's SDK version + private boolean requestRequiredPermission(PreDownloadInfo preDownloadInfo) { + + List permissions = new ArrayList<>(); + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && + ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED && + defaultDownloadLocation == DownloadLocation.PUBLIC_DOWNLOADS) { + permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + if (permissions.size() > 0) { + this.preDownloadInfo = preDownloadInfo; + requestPermissionLauncher.launch(permissions.toArray(new String[] {})); + return true; + } + return false; + } + + public String getLastDownloadedUrl() { + return lastDownloadedUrl; + } + + public void setUrlNavigation(UrlNavigation urlNavigation) { + this.urlNavigation = urlNavigation; + } + + public void unbindDownloadService() { + if (isBound) { + context.unbindService(serviceConnection); + isBound = false; + } + } + + public static Uri createExternalFileUri(ContentResolver contentResolver, String filename, String mimetype, String environment) { + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename); + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimetype); + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, environment); + + Uri baseExternalUri = getBaseExternalUriForEnvironment(environment); + + if (baseExternalUri == null) { + return null; + } + + return createFileUri(baseExternalUri, contentResolver, contentValues, filename, mimetype); + } + + private static Uri createFileUri(Uri baseExternalUri, ContentResolver contentResolver, ContentValues contentValues, String filename, String mimetype) { + try { + // Let the system create the unique URI first. Some device models(e.g., Samsung) will throw IllegalStateException + // https://stackoverflow.com/questions/61654022/java-lang-illegalstateexception-failed-to-build-unique-file-storage-emulated + Uri uri = contentResolver.insert(baseExternalUri, contentValues); + + // On certain Android versions (e.g., Android 10), a null URI may be returned due to a System-thrown SQLiteConstraintException. + // We handle this by forcibly creating one. + if (uri == null) { + uri = createUniqueFileUri(baseExternalUri, contentResolver, contentValues, filename, mimetype); + } + return uri; + } catch (IllegalStateException ex) { + return createUniqueFileUri(baseExternalUri, contentResolver, contentValues, filename, mimetype); + } + } + + private static Uri createUniqueFileUri(Uri baseExternalUri, ContentResolver contentResolver, ContentValues contentValues, String filename, String mimetype) { + try { + String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimetype); + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, getUniqueExternalFileName(contentResolver, baseExternalUri, filename, extension)); + return contentResolver.insert(baseExternalUri, contentValues); + } catch (IllegalStateException ex) { + return createUniqueFileUriWithTimeStamp(baseExternalUri, contentResolver, contentValues, filename); + } + } + + private static Uri createUniqueFileUriWithTimeStamp(Uri baseExternalUri, ContentResolver contentResolver, ContentValues contentValues, String filename) { + try { + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename + "_" + System.currentTimeMillis()); + return contentResolver.insert(baseExternalUri, contentValues); + } catch (IllegalStateException ex) { + return null; + } + } + + public static String getFileNameFromUri(Uri uri, ContentResolver contentResolver) { + String fileName = null; + + String[] projection = {MediaStore.MediaColumns.DISPLAY_NAME}; + Cursor cursor = contentResolver.query(uri, projection, null, null, null); + + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + int columnIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); + fileName = cursor.getString(columnIndex); + } + } finally { + cursor.close(); + } + } + + return fileName; + } + + private static Uri getBaseExternalUriForEnvironment(String environment) { + if (Objects.equals(environment, Environment.DIRECTORY_PICTURES)) { + return MediaStore.Images.Media.getContentUri("external"); + } else if (Objects.equals(environment, Environment.DIRECTORY_DOWNLOADS)) { + return MediaStore.Files.getContentUri("external"); + } + return null; + } + + private static String getUniqueExternalFileName(ContentResolver contentResolver, Uri baseUri, String filename, String extension) { + int suffix = 1; + String newFilename = filename; + + while (externalFileExists(contentResolver, baseUri, newFilename + "." + extension)) { + newFilename = filename + " (" + suffix + ")"; + suffix++; + } + + return newFilename; + } + + private static boolean externalFileExists(ContentResolver contentResolver, Uri baseUri, String filename) { + String[] projection = {MediaStore.MediaColumns.DISPLAY_NAME}; + String selection = MediaStore.MediaColumns.DISPLAY_NAME + "=?"; + String[] selectionArgs = {filename}; + + try (Cursor cursor = contentResolver.query(baseUri, projection, selection, selectionArgs, null)) { + if (cursor != null && cursor.moveToFirst()) { + return true; + } + } catch (Exception e) { + Log.w(TAG, "externalFileExists: ", e); + } + + return false; + } + + public static File createOutputFile(File dir, String filename, String extension) { + return new File(dir, FileDownloader.getUniqueFileName(filename + "." + extension, dir)); + } + + public static String getUniqueFileName(String fileName, File dir) { + File file = new File(dir, fileName); + + if (!file.exists()) { + return fileName; + } + + int count = 1; + String nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.')); + String ext = fileName.substring(fileName.lastIndexOf('.')); + String newFileName = nameWithoutExt + "_" + count + ext; + file = new File(dir, newFileName); + + while (file.exists()) { + count++; + newFileName = nameWithoutExt + "_" + count + ext; + file = new File(dir, newFileName); + } + + return file.getName(); + } + + public static String getFilenameExtension(String name) { + int pos = name.lastIndexOf('.'); + if (pos == -1) { + return null; + } else if (pos == 0) { + return name; + } else { + return name.substring(pos + 1); + } + } + + private static class PreDownloadInfo { + String url; + String filename; + String mimetype; + boolean shouldSaveToGallery; + boolean open; + boolean isBlob; + + public PreDownloadInfo(String url, String filename, String mimetype, boolean shouldSaveToGallery, boolean open, boolean isBlob) { + this.url = url; + this.filename = filename; + this.mimetype = mimetype; + this.shouldSaveToGallery = shouldSaveToGallery; + this.open = open; + this.isBlob = isBlob; + } + + public PreDownloadInfo(String url, String filename, boolean isBlob, boolean open) { + this.url = url; + this.filename = filename; + this.isBlob = isBlob; + this.open = open; + } + } +} diff --git a/app/src/main/java/co/median/android/FileUploadIntentsCreator.kt b/app/src/main/java/co/median/android/FileUploadIntentsCreator.kt new file mode 100644 index 0000000..76ee685 --- /dev/null +++ b/app/src/main/java/co/median/android/FileUploadIntentsCreator.kt @@ -0,0 +1,204 @@ +package co.median.android + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.* +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build +import android.os.Parcelable +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import co.median.android.R +import co.median.median_core.AppConfig +import co.median.median_core.Utils +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + + +class FileUploadIntentsCreator(val context: Context, val mimeTypeSpecs: Array, val multiple: Boolean) { + private val mimeTypes = hashSetOf() + private val appConfig = AppConfig.getInstance(context) + private var packageManger = context.packageManager + + var currentCaptureUri: Uri? = null + + init { + extractMimeTypes() + } + + private fun extractMimeTypes() { + mimeTypeSpecs.forEach { spec -> + val specParts = spec.split("[,;\\s]") + specParts.forEach { + if (it.startsWith(".")) { + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(it.substring(1)) + mimeType?.let { it1 -> mimeTypes.add(it1) } + } else if (it.contains("/")) { + mimeTypes.add(it) + } + } + } + + if (mimeTypes.isEmpty()) { + mimeTypes.add("*/*") + } + } + + private fun imagesAllowed(): Boolean { + if (!Utils.isPermissionGranted(context as Activity, android.Manifest.permission.CAMERA)) return false + return canUploadImage(); + } + + private fun videosAllowed(): Boolean { + if (!Utils.isPermissionGranted(context as Activity, android.Manifest.permission.CAMERA)) return false + return canUploadVideo() + } + + fun canUploadImage(): Boolean { + return mimeTypes.contains("*/*") || mimeTypes.any { it.contains("image/") } + } + + fun canUploadVideo(): Boolean { + return mimeTypes.contains("*/*") || mimeTypes.any { it.contains("video/") } + } + + private fun photoCameraIntents(): ArrayList { + val intents = arrayListOf() + + if (!appConfig.directCameraUploads) { + return intents + } + + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val imageFileName = "IMG_$timeStamp.jpg" + val storageDir = this.context.filesDir + val captureFile = File(storageDir, imageFileName) + + currentCaptureUri = FileProvider.getUriForFile(context, context.applicationContext.packageName + ".fileprovider", captureFile); + + val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + val resolveList: List = listOfAvailableAppsForIntent(captureIntent) + for (resolve in resolveList) { + val packageName = resolve.activityInfo.packageName + val intent = Intent(captureIntent) + intent.component = ComponentName(resolve.activityInfo.packageName, resolve.activityInfo.name) + intent.setPackage(packageName) + intent.putExtra(MediaStore.EXTRA_OUTPUT, currentCaptureUri) + intents.add(intent) + } + + return intents + } + + private fun videoCameraIntents(): ArrayList { + val intents = arrayListOf() + + if (!appConfig.directCameraUploads) { + return intents + } + + val captureIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE) + val resolveList: List = listOfAvailableAppsForIntent(captureIntent) + for (resolve in resolveList) { + val packageName = resolve.activityInfo.packageName + val intent = Intent(captureIntent) + intent.component = ComponentName(resolve.activityInfo.packageName, resolve.activityInfo.name) + intent.setPackage(packageName) + intents.add(intent) + } + + return intents + } + + private fun filePickerIntent(): Intent { + var intent: Intent + intent = Intent(Intent.ACTION_GET_CONTENT) // or ACTION_OPEN_DOCUMENT + intent.type = mimeTypes.joinToString(", ") + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray()) + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple) + intent.addCategory(Intent.CATEGORY_OPENABLE) + + val resolveList: List = listOfAvailableAppsForIntent(intent) + + if (resolveList.isEmpty() && Build.MANUFACTURER.equals("samsung", ignoreCase = true)) { + intent = Intent("com.sec.android.app.myfiles.PICK_DATA") + intent.putExtra("CONTENT_TYPE", "*/*") + intent.addCategory(Intent.CATEGORY_DEFAULT) + return intent + } + + return intent + } + + fun cameraIntent(): Intent { + val mediaIntents = if (imagesAllowed()) { + photoCameraIntents() + } else { + videoCameraIntents() + } + return mediaIntents.first() + } + + @SuppressLint("IntentReset") + fun chooserIntent(): Intent { + val directCaptureIntents = arrayListOf() + if (imagesAllowed()) { + directCaptureIntents.addAll(photoCameraIntents()) + } + if (videosAllowed()) { + directCaptureIntents.addAll(videoCameraIntents()) + } + + val chooserIntent: Intent? + val mediaIntent: Intent? + + if (imagesAllowed() xor videosAllowed()) { + mediaIntent = getMediaInitialIntent() + mediaIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple) + chooserIntent = Intent.createChooser(mediaIntent, context.getString(R.string.choose_action)) + } else if (onlyImagesAndVideo() && !isGooglePhotosDefaultApp()) { + mediaIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + mediaIntent.type = "image/*, video/*" + mediaIntent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) + mediaIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple) + chooserIntent = Intent.createChooser(mediaIntent, context.getString(R.string.choose_action)) + } else { + chooserIntent = Intent.createChooser(filePickerIntent(), context.getString(R.string.choose_action)) + } + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, directCaptureIntents.toTypedArray()) + + return chooserIntent + } + + private fun getMediaInitialIntent(): Intent { + return if (imagesAllowed()) { + Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + } else { + Intent(Intent.ACTION_PICK, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + } + } + + private fun onlyImagesAndVideo(): Boolean { + return mimeTypes.all { it.startsWith("image/") || it.startsWith("video/") } + } + + private fun isGooglePhotosDefaultApp(): Boolean { + val captureIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + val resolveList: List = listOfAvailableAppsForIntent(captureIntent) + + return resolveList.size == 1 && resolveList.first().activityInfo.packageName == "com.google.android.apps.photos" + } + + private fun listOfAvailableAppsForIntent(intent: Intent): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManger.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + packageManger.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/co/median/android/FileWriterSharer.java b/app/src/main/java/co/median/android/FileWriterSharer.java new file mode 100644 index 0000000..76b44a4 --- /dev/null +++ b/app/src/main/java/co/median/android/FileWriterSharer.java @@ -0,0 +1,321 @@ +package co.median.android; + +import android.Manifest; +import android.content.ActivityNotFoundException; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; +import android.webkit.JavascriptInterface; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + +import androidx.core.content.FileProvider; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; +import co.median.median_core.LeanUtils; + +public class FileWriterSharer { + private static final String TAG = FileWriterSharer.class.getSimpleName(); + private static final long MAX_SIZE = 1024 * 1024 * 1024; // 1 gigabyte + private static final String BASE64TAG = ";base64,"; + private final FileDownloader.DownloadLocation defaultDownloadLocation; + private String downloadFilename; + private boolean open = false; + + private static class FileInfo{ + public String id; + public String name; + public long size; + public String mimetype; + public String extension; + public File savedFile; + public Uri savedUri; + public OutputStream fileOutputStream; + public long bytesWritten; + } + + private class JavascriptBridge { + @JavascriptInterface + public void postMessage(String jsonMessage) { + Log.d(TAG, "got message " + jsonMessage); + try { + JSONObject json = new JSONObject(jsonMessage); + String event = LeanUtils.optString(json, "event"); + if ("fileStart".equals(event)) { + onFileStart(json); + } else if ("fileChunk".equals(event)) { + onFileChunk(json); + } else if ("fileEnd".equals(event)) { + onFileEnd(json); + } else if ("nextFileInfo".equals(event)) { + onNextFileInfo(json); + } else { + GNLog.getInstance().logError(TAG, "Invalid event " + event); + } + } catch (JSONException e) { + GNLog.getInstance().logError(TAG, "Error parsing message as json", e); + } catch (IOException e) { + GNLog.getInstance().logError(TAG, "IO Error", e); + } + } + } + + private JavascriptBridge javascriptBridge; + private MainActivity context; + private Map idToFileInfo; + private String nextFileName; + + public FileWriterSharer(MainActivity context) { + this.javascriptBridge = new JavascriptBridge(); + this.context = context; + this.idToFileInfo = new HashMap<>(); + + AppConfig appConfig = AppConfig.getInstance(this.context); + if (appConfig.downloadToPublicStorage) { + this.defaultDownloadLocation = FileDownloader.DownloadLocation.PUBLIC_DOWNLOADS; + } else { + this.defaultDownloadLocation = FileDownloader.DownloadLocation.PRIVATE_INTERNAL; + } + } + + public JavascriptBridge getJavascriptBridge() { + return javascriptBridge; + } + + public void downloadBlobUrl(String url, String filename, boolean open) { + if (url == null || !url.startsWith("blob:")) { + return; + } + + this.downloadFilename = filename; + this.open = open; + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + BufferedInputStream is = new BufferedInputStream(context.getAssets().open("BlobDownloader.js")); + IOUtils.copy(is, baos); + String js = baos.toString(); + context.runJavascript(js); + js = "gonativeDownloadBlobUrl(" + LeanUtils.jsWrapString(url) + ")"; + context.runJavascript(js); + } catch (IOException e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + } + + private void onFileStart(JSONObject message) throws IOException { + String identifier = LeanUtils.optString(message, "id"); + if (identifier == null || identifier.isEmpty()) { + GNLog.getInstance().logError(TAG, "Invalid file id"); + return; + } + + String fileName; + String extension = null; + String type = null; + + if (!TextUtils.isEmpty(downloadFilename)) { + extension = FileDownloader.getFilenameExtension(downloadFilename); + if (!TextUtils.isEmpty(extension)) { + if (Objects.equals(extension, downloadFilename)) { + fileName = "download"; + } else { + fileName = downloadFilename.substring(0, downloadFilename.length() - (extension.length() + 1)); + } + type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + } else { + fileName = downloadFilename; + } + } else { + fileName = LeanUtils.optString(message, "name"); + if (fileName == null || fileName.isEmpty()) { + if (this.nextFileName != null) { + fileName = this.nextFileName; + this.nextFileName = null; + } else { + fileName = "download"; + } + } + } + + long fileSize = message.optLong("size", -1); + if (fileSize <= 0 || fileSize > MAX_SIZE) { + GNLog.getInstance().logError(TAG, "Invalid file size"); + return; + } + + if (TextUtils.isEmpty(type)) { + type = LeanUtils.optString(message, "type"); + if (TextUtils.isEmpty(type)) { + GNLog.getInstance().logError(TAG, "Invalid file type"); + return; + } + } + + if (TextUtils.isEmpty(extension)) { + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + extension = mimeTypeMap.getExtensionFromMimeType(type); + } + + final FileInfo info = new FileInfo(); + info.id = identifier; + info.name = fileName; + info.size = fileSize; + info.mimetype = type; + info.extension = extension; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && defaultDownloadLocation == FileDownloader.DownloadLocation.PUBLIC_DOWNLOADS) { + // request permissions + context.getPermission(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, (permissions, grantResults) -> { + try { + onFileStartAfterPermission(info, grantResults[0] == PackageManager.PERMISSION_GRANTED); + final String js = "gonativeGotStoragePermissions()"; + context.runOnUiThread(() -> context.runJavascript(js)); + } catch (IOException e) { + GNLog.getInstance().logError(TAG, "IO Error", e); + } + }); + } else { + onFileStartAfterPermission(info, true); + final String js = "gonativeGotStoragePermissions()"; + context.runOnUiThread(() -> context.runJavascript(js)); + } + } + + private void onFileStartAfterPermission(FileInfo info, boolean granted) throws IOException { + if (granted && defaultDownloadLocation == FileDownloader.DownloadLocation.PUBLIC_DOWNLOADS) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + ContentResolver contentResolver = context.getApplicationContext().getContentResolver(); + Uri uri = FileDownloader.createExternalFileUri(contentResolver, info.name, info.mimetype, Environment.DIRECTORY_DOWNLOADS); + if (uri != null) { + info.fileOutputStream = contentResolver.openOutputStream(uri); + info.savedUri = uri; + } + } else { + info.savedFile = FileDownloader.createOutputFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), info.name, info.extension); + info.fileOutputStream = new BufferedOutputStream(new FileOutputStream(info.savedFile)); + } + } else { + info.savedFile = FileDownloader.createOutputFile(context.getFilesDir(), info.name, info.extension); + info.fileOutputStream = new BufferedOutputStream(new FileOutputStream(info.savedFile)); + } + info.bytesWritten = 0; + this.idToFileInfo.put(info.id, info); + } + + private void onFileChunk(JSONObject message) throws IOException { + String identifier = LeanUtils.optString(message, "id"); + if (identifier == null || identifier.isEmpty()) { + return; + } + + FileInfo fileInfo = this.idToFileInfo.get(identifier); + if (fileInfo == null) { + return; + } + + String data = LeanUtils.optString(message, "data"); + if (data == null) { + return; + } + + int idx = data.indexOf(BASE64TAG); + if (idx == -1) { + return; + } + + idx += BASE64TAG.length(); + byte[] chunk = Base64.decode(data.substring(idx), Base64.DEFAULT); + + if (fileInfo.bytesWritten + chunk.length > fileInfo.size) { + GNLog.getInstance().logError(TAG, "Received too many bytes. Expected " + fileInfo.size); + try { + fileInfo.fileOutputStream.close(); + fileInfo.savedFile.delete(); + this.idToFileInfo.remove(identifier); + } catch (Exception ignored) { + + } + + return; + } + + fileInfo.fileOutputStream.write(chunk); + fileInfo.bytesWritten += chunk.length; + } + + private void onFileEnd(JSONObject message) throws IOException { + String identifier = LeanUtils.optString(message, "id"); + if (identifier == null || identifier.isEmpty()) { + GNLog.getInstance().logError(TAG, "Invalid identifier " + identifier + " for fileEnd"); + return; + } + + final FileInfo fileInfo = this.idToFileInfo.get(identifier); + if (fileInfo == null) { + GNLog.getInstance().logError(TAG, "Invalid identifier " + identifier + " for fileEnd"); + return; + } + + fileInfo.fileOutputStream.close(); + + if (open) { + context.runOnUiThread(() -> { + if (fileInfo.savedUri == null && fileInfo.savedFile != null) { + fileInfo.savedUri = FileProvider.getUriForFile(context, context.getApplicationContext().getPackageName() + ".fileprovider", fileInfo.savedFile); + } + if (fileInfo.savedUri == null) return; + + Intent intent = getIntentToOpenFile(fileInfo.savedUri, fileInfo.mimetype); + try { + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + String message1 = context.getResources().getString(R.string.file_handler_not_found); + Toast.makeText(context, message1, Toast.LENGTH_LONG).show(); + } + }); + } else { + String downloadCompleteMessage = fileInfo.name != null && !fileInfo.name.isEmpty() + ? String.format(context.getString(R.string.file_download_finished_with_name), fileInfo.name + '.' + fileInfo.extension) + : context.getString(R.string.file_download_finished); + Toast.makeText(context, downloadCompleteMessage, Toast.LENGTH_SHORT).show(); + } + } + + private Intent getIntentToOpenFile(Uri uri, String mimetype) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, mimetype); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK); + return intent; + } + + private void onNextFileInfo(JSONObject message) { + String name = LeanUtils.optString(message, "name"); + if (name == null || name.isEmpty()) { + GNLog.getInstance().logError(TAG, "Invalid name for nextFileInfo"); + return; + } + this.nextFileName = name; + } +} diff --git a/app/src/main/java/co/median/android/GoNativeApplication.java b/app/src/main/java/co/median/android/GoNativeApplication.java new file mode 100644 index 0000000..6b7329e --- /dev/null +++ b/app/src/main/java/co/median/android/GoNativeApplication.java @@ -0,0 +1,116 @@ +package co.median.android; + +import android.content.ComponentCallbacks2; +import android.os.Message; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatDelegate; +import androidx.multidex.MultiDexApplication; + +import java.util.List; +import java.util.Map; + +import co.median.median_core.AppConfig; +import co.median.median_core.Bridge; +import co.median.median_core.BridgeModule; +import co.median.median_core.GNLog; + +/** + * Created by weiyin on 9/2/15. + * Copyright 2014 GoNative.io LLC + */ +public class GoNativeApplication extends MultiDexApplication { + + private LoginManager loginManager; + private RegistrationManager registrationManager; + private WebViewPool webViewPool; + private Message webviewMessage; + private GoNativeWindowManager goNativeWindowManager; + private List plugins; + private final static String TAG = GoNativeApplication.class.getSimpleName(); + public final Bridge mBridge = new Bridge(this) { + @Override + protected List getPlugins() { + if (GoNativeApplication.this.plugins == null) { + GoNativeApplication.this.plugins = new PackageList(GoNativeApplication.this).getPackages(); + } + + return GoNativeApplication.this.plugins; + } + }; + + private boolean appBackgrounded = false; + + @Override + public void onCreate() { + super.onCreate(); + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); + + mBridge.onApplicationCreate(this); + + AppConfig appConfig = AppConfig.getInstance(this); + if (appConfig.configError != null) { + Toast.makeText(this, "Invalid appConfig json", Toast.LENGTH_LONG).show(); + GNLog.getInstance().logError(TAG, "AppConfig error", appConfig.configError); + } + + this.loginManager = new LoginManager(this); + + if (appConfig.registrationEndpoints != null) { + this.registrationManager = new RegistrationManager(this); + registrationManager.processConfig(appConfig.registrationEndpoints); + } + + // some global webview setup + WebViewSetup.setupWebviewGlobals(this); + + webViewPool = new WebViewPool(); + + goNativeWindowManager = new GoNativeWindowManager(); + } + + public LoginManager getLoginManager() { + return loginManager; + } + + public RegistrationManager getRegistrationManager() { + return registrationManager; + } + + public WebViewPool getWebViewPool() { + return webViewPool; + } + + public Message getWebviewMessage() { + return webviewMessage; + } + + public void setWebviewMessage(Message webviewMessage) { + this.webviewMessage = webviewMessage; + } + + public Map getAnalyticsProviderInfo() { + return mBridge.getAnalyticsProviderInfo(); + } + + public GoNativeWindowManager getWindowManager() { + return goNativeWindowManager; + } + + @Override + public void onTrimMemory(int level) { + super.onTrimMemory(level); + if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { + // App has gone into the background + setAppBackgrounded(true); + } + } + + public boolean isAppBackgrounded() { + return appBackgrounded; + } + + public void setAppBackgrounded(boolean appBackgrounded) { + this.appBackgrounded = appBackgrounded; + } +} diff --git a/app/src/main/java/co/median/android/GoNativeWindowManager.java b/app/src/main/java/co/median/android/GoNativeWindowManager.java new file mode 100644 index 0000000..0e47063 --- /dev/null +++ b/app/src/main/java/co/median/android/GoNativeWindowManager.java @@ -0,0 +1,153 @@ +package co.median.android; + +import android.text.TextUtils; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class GoNativeWindowManager { + private final LinkedHashMap windows; + private ExcessWindowsClosedListener excessWindowsClosedListener; + + public GoNativeWindowManager() { + windows = new LinkedHashMap<>(); + } + + public void addNewWindow(String activityId, boolean isRoot) { + this.windows.put(activityId, new ActivityWindow(activityId, isRoot)); + } + + public void removeWindow(String activityId) { + this.windows.remove(activityId); + + if (excessWindowsClosedListener != null && windows.size() <= 1) { + excessWindowsClosedListener.onAllExcessWindowClosed(); + } + } + + public void setOnExcessWindowClosedListener(ExcessWindowsClosedListener listener) { + this.excessWindowsClosedListener = listener; + } + + public ActivityWindow getActivityWindowInfo(String activityId) { + return windows.get(activityId); + } + + public void setUrlLevel(String activityId, int urlLevel) { + ActivityWindow window = windows.get(activityId); + if (window != null) { + window.setUrlLevels(urlLevel, window.parentUrlLevel); + } + } + + public int getUrlLevel(String activityId) { + ActivityWindow window = windows.get(activityId); + if (window != null) { + return window.urlLevel; + } + return -1; + } + + public void setParentUrlLevel(String activityId, int parentLevel) { + ActivityWindow window = windows.get(activityId); + if (window != null) { + window.setUrlLevels(window.urlLevel, parentLevel); + } + } + + public int getParentUrlLevel(String activityId) { + ActivityWindow window = windows.get(activityId); + if (window != null) { + return window.parentUrlLevel; + } + return -1; + } + + public void setUrlLevels(String activityId, int urlLevel, int parentLevel) { + ActivityWindow window = windows.get(activityId); + if (window != null) { + window.setUrlLevels(urlLevel, parentLevel); + } + } + + public boolean isRoot(String activityId) { + ActivityWindow window = windows.get(activityId); + if (window != null) { + return window.isRoot; + } + return false; + } + + public void setAsNewRoot(String activityId) { + for (Map.Entry entry : windows.entrySet()) { + ActivityWindow window = entry.getValue(); + if (TextUtils.equals(activityId, entry.getKey())) { + window.isRoot = true; + } else { + window.isRoot = false; + } + } + } + + public void setIgnoreInterceptMaxWindows(String activityId, boolean ignore) { + ActivityWindow window = windows.get(activityId); + if (window != null) { + window.ignoreInterceptMaxWindows = ignore; + } + } + + public boolean isIgnoreInterceptMaxWindows(String activityId) { + ActivityWindow window = windows.get(activityId); + if (window != null) { + return window.ignoreInterceptMaxWindows; + } + return false; + } + + public int getWindowCount() { + return windows.size(); + } + + // Returns ID of the next window after root as Excess window + public String getExcessWindow() { + for (Map.Entry entry : windows.entrySet()) { + ActivityWindow window = entry.getValue(); + if (window.isRoot) continue; + return window.id; + } + return null; + } + + public static class ActivityWindow { + private final String id; + private boolean isRoot; + private int urlLevel; + private int parentUrlLevel; + private boolean ignoreInterceptMaxWindows; + + ActivityWindow(String id, boolean isRoot) { + this.id = id; + this.isRoot = isRoot; + this.urlLevel = -1; + this.parentUrlLevel = -1; + } + + public void setUrlLevels(int urlLevel, int parentUrlLevel) { + this.urlLevel = urlLevel; + this.parentUrlLevel = parentUrlLevel; + } + + @Override + public String toString() { + return "id=" + id + "\n" + + "isRoot=" + isRoot + "\n" + + "urlLevel=" + urlLevel + "\n" + + "parentUrlLevel=" + parentUrlLevel; + } + } + + + interface ExcessWindowsClosedListener { + void onAllExcessWindowClosed(); + } +} diff --git a/app/src/main/java/co/median/android/HtmlIntercept.java b/app/src/main/java/co/median/android/HtmlIntercept.java new file mode 100644 index 0000000..64c4d60 --- /dev/null +++ b/app/src/main/java/co/median/android/HtmlIntercept.java @@ -0,0 +1,273 @@ +package co.median.android; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Locale; +import java.util.Map; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; +import co.median.median_core.GoNativeWebviewInterface; + +/** + * Created by weiyin on 1/29/16. + */ + +public class HtmlIntercept { + private static final String TAG = HtmlIntercept.class.getName(); + + private Context context; + private String interceptUrl; + private String JSBridgeScript; + private String redirectedUrl; + + // track whether we have intercepted a page at all. We will always try to intercept the first time, + // because interceptUrl may not have been set if restoring from a bundle. + private boolean hasIntercepted = false; + + HtmlIntercept(Context context) { + this.context = context; + } + + public void setInterceptUrl(String interceptUrl) { + this.interceptUrl = interceptUrl; + } + + public WebResourceResponse interceptHtml(GoNativeWebviewInterface view, String url, String referer) { + + AppConfig appConfig = AppConfig.getInstance(context); + if (!appConfig.interceptHtml && (appConfig.customHeaders == null || appConfig.customHeaders.isEmpty())) return null; + + if (!hasIntercepted) { + interceptUrl = url; + hasIntercepted = true; + } + if (!urlMatches(interceptUrl, url)) return null; + + InputStream is = null; + ByteArrayOutputStream baos = null; + + try { + URL parsedUrl = new URL(url); + String protocol = parsedUrl.getProtocol(); + if (!protocol.equalsIgnoreCase("http") && !protocol.equalsIgnoreCase("https")) return null; + + HttpURLConnection connection = (HttpURLConnection)parsedUrl.openConnection(); + connection.setInstanceFollowRedirects(false); + String customUserAgent = appConfig.userAgentForUrl(parsedUrl.toString()); + if (customUserAgent != null) { + connection.setRequestProperty("User-Agent", customUserAgent); + } else { + connection.setRequestProperty("User-Agent", appConfig.userAgent); + } + connection.setRequestProperty("Cache-Control", "no-cache"); + + if (referer != null) { + connection.setRequestProperty("Referer", referer); + } + + Map customHeaders = CustomHeaders.getCustomHeaders(context); + if (customHeaders != null) { + for (Map.Entry entry : customHeaders.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + } + + connection.connect(); + int responseCode = connection.getResponseCode(); + + if (responseCode == HttpURLConnection.HTTP_MOVED_PERM || + responseCode == HttpURLConnection.HTTP_MOVED_TEMP || + responseCode == HttpURLConnection.HTTP_SEE_OTHER || + responseCode == 307) { + // Get redirect URL to be loaded directly to webview, return blank resource which we cancel on UrlNavigation.onPageStart() + // We cannot pass headers in webresourceresponse until Android API 21, and we cannot return null + // or else the webview will handle the request entirely without intercept + String location = connection.getHeaderField("Location"); + + // validate location as URL + try { + new URL(location); + } catch (MalformedURLException ex) { + URL base = new URL(url); + location = new URL(base, location).toString(); + } + + if (!TextUtils.isEmpty(location)) { + this.redirectedUrl = url; + MainActivity mainActivity = (MainActivity) context; + WebView webView = (WebView) mainActivity.getWebView(); + String finalLocation = location; // needed, for this should be effectively final + webView.post(() -> webView.loadUrl(finalLocation)); + } + return new WebResourceResponse("text/html", "utf-8", new ByteArrayInputStream("".getBytes())); + } + + String mimetype = connection.getContentType(); + if (mimetype == null) { + try { + is = new BufferedInputStream(connection.getInputStream()); + } catch (IOException e) { + is = new BufferedInputStream(connection.getErrorStream()); + } + mimetype = HttpURLConnection.guessContentTypeFromStream(is); + } + + // if not html, then return null so that webview loads directly. + if (mimetype == null || !mimetype.startsWith("text/html")) + return null; + + // get and intercept the data + String characterEncoding = getCharset(mimetype); + if (characterEncoding == null) { + characterEncoding = "UTF-8"; + } else if (characterEncoding.toLowerCase().equals("iso-8859-1")) { + // windows-1252 is a superset of ios-8859-1 that supports the euro symbol €. + // The html5 spec actually maps "iso-8859-1" to windows-1252 encoding + characterEncoding = "windows-1252"; + } + + if (is == null) { + try { + is = new BufferedInputStream(connection.getInputStream()); + } catch (IOException e) { + is = new BufferedInputStream(connection.getErrorStream()); + } + } + + int initialLength = connection.getContentLength(); + if (initialLength < 0) + initialLength = UrlNavigation.DEFAULT_HTML_SIZE; + + baos = new ByteArrayOutputStream(initialLength); + IOUtils.copy(is, baos); + String origString; + try { + origString = baos.toString(characterEncoding); + } catch (UnsupportedEncodingException e){ + // Everything should support UTF-8 + origString = baos.toString("UTF-8"); + } + + // modify the string! + String newString; + int insertPoint = origString.indexOf(""); + if (insertPoint >= 0) { + StringBuilder builder = new StringBuilder(initialLength); + builder.append(origString.substring(0, insertPoint)); + + if (appConfig.stringViewport != null) { + builder.append(""); + } + if (!Double.isNaN(appConfig.forceViewportWidth)) { + if (appConfig.zoomableForceViewport) { + builder.append(String.format(Locale.US, "", + appConfig.forceViewportWidth)); + } + else { + // we want to use user-scalable=no, but android has a bug that sets scale to + // 1.0 if user-scalable=no. The workaround to is calculate the scale and set + // it for initial, minimum, and maximum. + // http://stackoverflow.com/questions/12723844/android-viewport-setting-user-scalable-no-breaks-width-zoom-level-of-viewpor + double webViewWidth = view.getWidth() / context.getResources().getDisplayMetrics().density; + double viewportWidth = appConfig.forceViewportWidth; + double scale = webViewWidth / viewportWidth; + builder.append(String.format(Locale.US, "", + viewportWidth, scale, scale, scale)); + } + } + + builder.append(origString.substring(insertPoint)); + newString = builder.toString(); + } + else { + Log.d(TAG, "could not find closing tag"); + newString = origString; + } + + return new WebResourceResponse("text/html", "UTF-8", + new ByteArrayInputStream(newString.getBytes("UTF-8"))); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.toString(), e); + return null; + } finally { + IOUtils.close(is); + IOUtils.close(baos); + } + } + + // Do these urls match, ignoring trailing slash in path + private static boolean urlMatches(String url1, String url2) { + if (url1 == null || url2 == null) return false; + + try { + URL parsed1 = new URL(url1); + URL parsed2 = new URL(url2); + + if (stringsNotEqual(parsed1.getProtocol(), parsed2.getProtocol())) return false; + + if (stringsNotEqual(parsed1.getAuthority(), parsed2.getAuthority())) return false; + + if (stringsNotEqual(parsed1.getQuery(), parsed2.getQuery())) return false; + + String path1 = parsed1.getPath(); + String path2 = parsed2.getPath(); + if (path1 == null) path1 = ""; + if (path2 == null) path2 = ""; + + int lengthDiff = path2.length() - path2.length(); + if (lengthDiff > 1 || lengthDiff < -1) return false; + if (lengthDiff == 0) return path1.equals(path2); + if (lengthDiff == 1) { + return path2.equals(path1 + "/"); + } + + // lengthDiff == -1 + return path1.equals(path2 + "/"); + } catch (MalformedURLException e) { + return false; + } + } + + private static boolean stringsNotEqual(String s1, String s2) { + return !(s1 == null ? s2 == null : s1.equals(s2)); + } + + private static String getCharset(String contentType) { + if (contentType == null || contentType.isEmpty()) { + return null; + } + + String[] tokens = contentType.split("; *"); + for (String s : tokens) { + if (s.startsWith("charset=")) { + return s.substring("charset=".length()); + } + } + + return null; + } + + public String getRedirectedUrl() { + return redirectedUrl; + } + + public void setRedirectedUrl(String redirectUrl) { + this.redirectedUrl = redirectUrl; + } +} diff --git a/app/src/main/java/co/median/android/IOUtils.java b/app/src/main/java/co/median/android/IOUtils.java new file mode 100644 index 0000000..9ee9ce5 --- /dev/null +++ b/app/src/main/java/co/median/android/IOUtils.java @@ -0,0 +1,30 @@ +package co.median.android; + + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import co.median.median_core.GNLog; + +public class IOUtils { + private static final String TAG = IOUtils.class.getName(); + + public static void copy(InputStream in, OutputStream out) throws IOException{ + byte[] buf = new byte[1024]; + int len; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } + } + + public static void close(Closeable c) { + if (c == null) return; + try { + c.close(); + } catch (IOException e){ + GNLog.getInstance().logError(TAG, e.toString(), e); + } + } +} diff --git a/app/src/main/java/co/median/android/Installation.java b/app/src/main/java/co/median/android/Installation.java new file mode 100644 index 0000000..18ef060 --- /dev/null +++ b/app/src/main/java/co/median/android/Installation.java @@ -0,0 +1,150 @@ +package co.median.android; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.telephony.SubscriptionInfo; +import android.telephony.SubscriptionManager; +import android.util.Log; + +import androidx.core.app.ActivityCompat; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; +import java.util.UUID; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; + +/** + * Created by weiyin on 8/8/14. + */ +public class Installation { + private static final String TAG = Installation.class.getName(); + + private static String sID = null; + private static final String INSTALLATION = "INSTALLATION"; + + public synchronized static String id(Context context) { + if (sID == null) { + File installation = new File(context.getFilesDir(), INSTALLATION); + try { + if (!installation.exists()) + writeInstallationFile(installation); + sID = readInstallationFile(installation); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return sID; + } + + public static Map getInfo(Context context) { + HashMap info = new HashMap<>(); + + info.put("platform", "android"); + + String publicKey = AppConfig.getInstance(context).publicKey; + if (publicKey == null) publicKey = ""; + info.put("publicKey", publicKey); + + String packageName = context.getPackageName(); + info.put("appId", packageName); + + + PackageManager manager = context.getPackageManager(); + try { + PackageInfo packageInfo = manager.getPackageInfo(packageName, 0); + info.put("appVersion", packageInfo.versionName); + info.put("appVersionCode", packageInfo.versionCode); + } catch (PackageManager.NameNotFoundException e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + + String distribution; + boolean isDebuggable = ( 0 != ( context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE ) ); + if (isDebuggable) { + distribution = "debug"; + } else { + String installer = manager.getInstallerPackageName(packageName); + if (installer == null) { + distribution = "adhoc"; + } else if (installer.equals("com.android.vending") || installer.equals("com.google.market")) { + distribution = "playstore"; + } else if (installer.equals("com.amazon.venezia")) { + distribution = "amazon"; + } else { + distribution = installer; + } + } + info.put("distribution", distribution); + + info.put("language", Locale.getDefault().getLanguage()); + info.put("os", "Android"); + info.put("osVersion", Build.VERSION.RELEASE); + info.put("model", Build.MANUFACTURER + " " + Build.MODEL); + info.put("hardware", Build.FINGERPRINT); + info.put("timeZone", TimeZone.getDefault().getID()); + info.put("deviceName", getDeviceName()); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1) { + SubscriptionManager subscriptionManager = SubscriptionManager.from(context); + + if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { + List carriers = new ArrayList<>(); + for (SubscriptionInfo subscriptionInfo : subscriptionManager.getActiveSubscriptionInfoList()) { + carriers.add(subscriptionInfo.getCarrierName().toString()); + } + info.put("carrierNames", carriers); + try { + info.put("carrierName", carriers.get(0)); + } catch ( IndexOutOfBoundsException e ) { + Log.w(TAG, "getInfo: No carriers registered with subscription manager"); + } + } else { + Log.w(TAG, "getInfo: Cannot get carrierNames, READ_PHONE_STATE not granted"); + } + } + + info.put("installationId", Installation.id(context)); + + return info; + } + + private static String readInstallationFile(File installation) throws IOException { + RandomAccessFile f = new RandomAccessFile(installation, "r"); + byte[] bytes = new byte[(int) f.length()]; + f.readFully(bytes); + f.close(); + return new String(bytes); + } + + private static void writeInstallationFile(File installation) throws IOException { + FileOutputStream out = new FileOutputStream(installation); + String id = UUID.randomUUID().toString(); + out.write(id.getBytes()); + out.close(); + } + + private static String getDeviceName() { + String manufacturer = Build.MANUFACTURER; + String model = Build.MODEL; + String name; + if (model.startsWith(manufacturer)) { + name = model; + } else { + name = manufacturer + " " + model; + } + return name; + } +} \ No newline at end of file diff --git a/app/src/main/java/co/median/android/JsCustomCodeExecutor.java b/app/src/main/java/co/median/android/JsCustomCodeExecutor.java new file mode 100644 index 0000000..6488d38 --- /dev/null +++ b/app/src/main/java/co/median/android/JsCustomCodeExecutor.java @@ -0,0 +1,64 @@ +package co.median.android; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Map; + +import co.median.median_core.GNLog; + +public class JsCustomCodeExecutor { + private static final String TAG = JsCustomCodeExecutor.class.getName(); + + public static interface CustomCodeHandler { + JSONObject execute(Map params); + } + + // The default CustomCodeHandler "Echo" + // Simply maps all the key/values of the given params into a JSONObject + private static CustomCodeHandler handler = new CustomCodeHandler() { + @Override + public JSONObject execute(Map params) { + if(params != null) { + JSONObject json = new JSONObject(); + try { + for(Map.Entry entry : params.entrySet()) { + json.put(entry.getKey(), entry.getValue()); + } + } + catch(JSONException e) { + GNLog.getInstance().logError(TAG, "Error building custom Json Data", e); + } + return json; + } + return null; + } + }; + + /** + * Set new CustomCodeHandler to override the default "Echo" handler + * @param customHandler + */ + public static void setHandler(CustomCodeHandler customHandler) { + if(customHandler == null) + return; + handler = customHandler; + } + + /** + * Code Handler gets triggered by the UrlNavigation class + * + * @param params A map consisting of all URI parameters and their values + * @return A JSONObject as defined by the Code Handler + * + * @see UrlNavigation#shouldOverrideUrlLoading + */ + public static JSONObject execute(Map params) { + try { + return handler.execute(params); + } catch(Exception e) { + GNLog.getInstance().logError(TAG, "Error executing custom code", e); + return null; + } + } +} diff --git a/app/src/main/java/co/median/android/JsResultBridge.java b/app/src/main/java/co/median/android/JsResultBridge.java new file mode 100644 index 0000000..e2412a6 --- /dev/null +++ b/app/src/main/java/co/median/android/JsResultBridge.java @@ -0,0 +1,5 @@ +package co.median.android; + +public class JsResultBridge { + public static String jsResult = ""; +} diff --git a/app/src/main/java/co/median/android/JsonMenuAdapter.java b/app/src/main/java/co/median/android/JsonMenuAdapter.java new file mode 100644 index 0000000..ad6ad68 --- /dev/null +++ b/app/src/main/java/co/median/android/JsonMenuAdapter.java @@ -0,0 +1,434 @@ +package co.median.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.StateListDrawable; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.ExpandableListView; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.json.JSONArray; +import org.json.JSONObject; + +import io.gonative.android.icons.Icon; +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; + +/** + * Created by weiyin on 4/14/14. + */ +public class JsonMenuAdapter extends BaseExpandableListAdapter + implements ExpandableListView.OnGroupClickListener, ExpandableListView.OnChildClickListener { + private static final String TAG = JsonMenuAdapter.class.getName(); + + private MainActivity mainActivity; + private JSONArray menuItems; + + private boolean groupsHaveIcons = false; + private boolean childrenHaveIcons = false; + private String status; + private int selectedIndex; + private ExpandableListView expandableListView; + private Integer highlightColor; + private int sidebar_icon_size; + private int sidebar_expand_indicator_size; + + JsonMenuAdapter(MainActivity activity, ExpandableListView expandableListView) { + this.mainActivity = activity; + sidebar_icon_size = mainActivity.getResources().getInteger(R.integer.sidebar_icon_size); + sidebar_expand_indicator_size = mainActivity.getResources().getInteger(R.integer.sidebar_expand_indicator_size); + this.expandableListView = expandableListView; + menuItems = null; + + this.highlightColor = activity.getResources().getColor(R.color.sidebarHighlight); + + // broadcast messages + BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction() != null && intent.getAction().equals(AppConfig.PROCESSED_MENU_MESSAGE)) { + update(); + } + } + }; + LocalBroadcastManager.getInstance(this.mainActivity) + .registerReceiver(broadcastReceiver, + new IntentFilter(AppConfig.PROCESSED_MENU_MESSAGE)); + + } + + private synchronized void update() { + update(this.status); + } + + public synchronized void update(String status) { + if (status == null) status = "default"; + this.status = status; + + menuItems = AppConfig.getInstance(mainActivity).menus.get(status); + if (menuItems == null) menuItems = new JSONArray(); + + // figure out groupsHaveIcons and childrenHaveIcons (for layout alignment) + groupsHaveIcons = false; + childrenHaveIcons = false; + for (int i = 0; i < menuItems.length(); i++) { + JSONObject item = menuItems.optJSONObject(i); + if (item == null) continue; + + if (!item.isNull("icon") && !item.optString("icon").isEmpty()) { + groupsHaveIcons = true; + } + + if (item.optBoolean("isGrouping", false)) { + JSONArray sublinks = item.optJSONArray("subLinks"); + if (sublinks != null) { + for (int j = 0; j < sublinks.length(); j++) { + JSONObject sublink = sublinks.optJSONObject(j); + if (sublink != null && !sublink.isNull("icon") && !sublink.optString("icon").isEmpty()) { + childrenHaveIcons = true; + break; + } + } + } + + } + } + + notifyDataSetChanged(); + } + + + private String itemString(String s, int groupPosition) { + String value = null; + try { + JSONObject section = (JSONObject) menuItems.get(groupPosition); + if (!section.isNull(s)) + value = section.getString(s).trim(); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + + return value; + } + + private String itemString(String s, int groupPosition, int childPosition) { + String value = null; + try { + JSONObject section = (JSONObject) menuItems.get(groupPosition); + JSONObject sublink = section.getJSONArray("subLinks").getJSONObject(childPosition); + if (!sublink.isNull(s)) + value = sublink.getString(s).trim(); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + + return value; + } + + private String getTitle(int groupPosition) { + return itemString("label", groupPosition); + } + + private String getTitle(int groupPosition, int childPosition) { + return itemString("label", groupPosition, childPosition); + } + + private Pair getUrlAndJavascript(int groupPosition) { + String url = itemString("url", groupPosition); + String js = itemString("javascript", groupPosition); + return new Pair<>(url, js); + } + + private Pair getUrlAndJavascript(int groupPosition, int childPosition) { + String url = itemString("url", groupPosition, childPosition); + String js = itemString("javascript", groupPosition, childPosition); + return new Pair<>(url, js); + } + + private boolean isGrouping(int groupPosition) { + try { + JSONObject section = (JSONObject) menuItems.get(groupPosition); + return section.optBoolean("isGrouping", false); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + return false; + } + } + + @Override + public int getGroupCount() { + return menuItems.length(); + } + + @Override + public int getChildrenCount(int groupPosition) { + int count = 0; + try { + JSONObject section = (JSONObject) menuItems.get(groupPosition); + if (section.optBoolean("isGrouping", false)) { + count = section.getJSONArray("subLinks").length(); + } else { + count = 0; + } + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + return count; + } + + @Override + public Object getGroup(int i) { + return null; + } + + @Override + public Object getChild(int i, int i2) { + return null; + } + + @Override + public long getGroupId(int i) { + return 0; + } + + @Override + public long getChildId(int i, int i2) { + return 0; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) { + if (convertView == null) { + LayoutInflater inflater = mainActivity.getLayoutInflater(); + + convertView = inflater.inflate(groupsHaveIcons ? + R.layout.menu_group_icon : R.layout.menu_group_noicon, null); + + + TextView title = convertView.findViewById(R.id.menu_item_title); + title.setTextColor(mainActivity.getResources().getColor(R.color.sidebarForeground)); + + } + RelativeLayout menuItem = convertView.findViewById(R.id.menu_item); + GradientDrawable shape = getHighlightDrawable(); + StateListDrawable stateListDrawable = new StateListDrawable(); + stateListDrawable.addState(new int[]{android.R.attr.state_activated}, shape); + stateListDrawable.addState(new int[]{android.R.attr.state_selected}, shape); + + menuItem.setBackground(stateListDrawable); + + // expand/collapse indicator + ImageView indicator = convertView.findViewById(R.id.menu_group_indicator); + if (isGrouping(groupPosition)) { + String iconName; + int color = Color.BLACK; + if (isExpanded) { + iconName = "fas fa-angle-up"; + } else { + iconName = "fas fa-angle-down"; + } + + if (groupPosition == this.selectedIndex) { + color = this.highlightColor; + } else { + color = mainActivity.getResources().getColor(R.color.sidebarForeground); + } + indicator.setImageDrawable(new Icon(mainActivity, iconName, sidebar_expand_indicator_size, color).getDrawable()); + + indicator.setVisibility(View.VISIBLE); + } else { + indicator.setVisibility(View.GONE); + } + + //set the title + TextView title = convertView.findViewById(R.id.menu_item_title); + title.setText(getTitle(groupPosition)); + if (this.selectedIndex == groupPosition) { + title.setTextColor(this.highlightColor); + } else { + title.setTextColor(mainActivity.getResources().getColor(R.color.sidebarForeground)); + } + + // set icon + String icon = itemString("icon", groupPosition); + ImageView imageView = convertView.findViewById(R.id.menu_item_icon); + if (icon != null && !icon.isEmpty()) { + int color; + if (groupPosition == this.selectedIndex) { + color = this.highlightColor; + } else { + color = mainActivity.getResources().getColor(R.color.sidebarForeground); + } + Drawable iconDrawable = new Icon(mainActivity, icon, sidebar_icon_size, color).getDrawable(); + + imageView.setImageDrawable(iconDrawable); + imageView.setVisibility(View.VISIBLE); + } else if (imageView != null) { + imageView.setVisibility(View.INVISIBLE); + } + + return convertView; + } + + @Override + public View getChildView(int groupPosition, int childPosition, boolean isLastChild, + View convertView, ViewGroup parent) { + + if (convertView == null) { + LayoutInflater inflater = mainActivity.getLayoutInflater(); + + if (groupsHaveIcons || childrenHaveIcons) + convertView = inflater.inflate(R.layout.menu_child_icon, parent, false); + else + convertView = inflater.inflate(R.layout.menu_child_noicon, parent, false); + + // style it + TextView title = convertView.findViewById(R.id.menu_item_title); + title.setTextColor(mainActivity.getResources().getColor(R.color.sidebarForeground)); + } + + RelativeLayout menuItem = convertView.findViewById(R.id.menu_item); + GradientDrawable shape = getHighlightDrawable(); + StateListDrawable stateListDrawable = new StateListDrawable(); + stateListDrawable.addState(new int[]{android.R.attr.state_activated}, shape); + stateListDrawable.addState(new int[]{android.R.attr.state_selected}, shape); + + menuItem.setBackground(stateListDrawable); + + // set title + TextView title = convertView.findViewById(R.id.menu_item_title); + title.setText(getTitle(groupPosition, childPosition)); + if (this.selectedIndex == (groupPosition + childPosition) + 1) { + title.setTextColor(this.highlightColor); + } else { + title.setTextColor(mainActivity.getResources().getColor(R.color.sidebarForeground)); + } + + // set icon + String icon = itemString("icon", groupPosition, childPosition); + ImageView imageView = convertView.findViewById(R.id.menu_item_icon); + if (icon != null && !icon.isEmpty()) { + int color; + if (this.selectedIndex == (groupPosition + childPosition) + 1) { + color = this.highlightColor; + } else { + color = mainActivity.getResources().getColor(R.color.sidebarForeground); + } + Drawable iconDrawable = new Icon(mainActivity, icon, sidebar_icon_size, color).getDrawable(); + + imageView.setImageDrawable(iconDrawable); + imageView.setVisibility(View.VISIBLE); + } else if (imageView != null) { + imageView.setVisibility(View.INVISIBLE); + } + + return convertView; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return true; + } + + @Override + public boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id) { + try { + if (isGrouping(groupPosition)) { + // return false for default handling behavior + return false; + } else { + Pair urlAndJavascript = getUrlAndJavascript(groupPosition); + loadUrlAndJavascript(urlAndJavascript.first, urlAndJavascript.second); + return true; // tell android that we have handled it + } + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + + return false; + } + + @Override + public boolean onChildClick(ExpandableListView parent, View v, int groupPosition, int childPosition, long id) { + int index = parent.getFlatListPosition(ExpandableListView.getPackedPositionForChild(groupPosition, childPosition)); + parent.setItemChecked(index, true); + this.selectedIndex = index; + Pair urlAndJavascript = getUrlAndJavascript(groupPosition, childPosition); + loadUrlAndJavascript(urlAndJavascript.first, urlAndJavascript.second); + return true; + } + + private void loadUrlAndJavascript(String url, String javascript) { + // check for GONATIVE_USERID + if (UrlInspector.getInstance().getUserId() != null) { + url = url.replaceAll("GONATIVE_USERID", UrlInspector.getInstance().getUserId()); + } + + if (javascript == null) mainActivity.loadUrl(url); + else mainActivity.loadUrlAndJavascript(url, javascript); + + mainActivity.closeDrawers(); + } + + public void autoSelectItem(String url) { + String formattedUrl = url.replaceAll("/$", ""); + if (menuItems == null) return; + + for (int i = 0; i < menuItems.length(); i++) { + if (formattedUrl.equals(menuItems.optJSONObject(i).optString("url").replaceAll("/$", ""))) { + expandableListView.setItemChecked(i, true); + selectedIndex = i; + return; + } + } + } + + private GradientDrawable getHighlightDrawable() { + GradientDrawable shape = new GradientDrawable(); + shape.setCornerRadius(10); + shape.setColor(this.highlightColor); + shape.setAlpha(30); + + return shape; + } + + @Override + public int getChildType(int groupPosition, int childPosition) { + if (groupsHaveIcons || childrenHaveIcons) return 0; + else return 1; + } + + @Override + public int getChildTypeCount() { + return 2; + } + + @Override + public int getGroupType(int groupPosition) { + if (groupsHaveIcons) return 0; + else return 1; + } + + @Override + public int getGroupTypeCount() { + return 2; + } +} diff --git a/app/src/main/java/co/median/android/KeyboardManager.kt b/app/src/main/java/co/median/android/KeyboardManager.kt new file mode 100644 index 0000000..f324882 --- /dev/null +++ b/app/src/main/java/co/median/android/KeyboardManager.kt @@ -0,0 +1,78 @@ +package co.median.android + +import android.graphics.Rect +import android.text.TextUtils +import android.view.ViewGroup +import co.median.median_core.LeanUtils +import org.json.JSONObject + + +class KeyboardManager(val activity: MainActivity, private val rootLayout: ViewGroup) { + + var callback: String? = "" + private var keyboardWidth = 0 + private var keyboardHeight = 0 + private var screenWidth = 0 + private var screenHeight = 0 + private var isKeyboardVisible = false + private var screenHeightOffset = 0 + + init { + rootLayout.viewTreeObserver + .addOnGlobalLayoutListener { + val r = Rect() + rootLayout.getWindowVisibleDisplayFrame(r) + + if (screenHeightOffset == 0) { + screenHeightOffset = rootLayout.rootView.height - r.bottom + } + + screenWidth = rootLayout.rootView.width + screenHeight = r.bottom + screenHeightOffset + + keyboardHeight = rootLayout.rootView.height - screenHeight + + if (keyboardHeight == screenHeightOffset) { + keyboardHeight = 0 + } + + val visible = keyboardHeight != 0 + + if (visible) { + keyboardWidth = screenWidth + if (!isKeyboardVisible) { + isKeyboardVisible = true + notifyCallback(); + } + } else { + keyboardWidth = 0 + if (isKeyboardVisible) { + isKeyboardVisible = false + notifyCallback(); + } + } + } + } + + private fun notifyCallback() { + if (TextUtils.isEmpty(callback)) return + activity.runJavascript(LeanUtils.createJsForCallback(callback, getKeyboardData())) + } + + fun getKeyboardData() : JSONObject { + val keyboardWindowSize = JSONObject() + keyboardWindowSize.put("width", keyboardWidth) + keyboardWindowSize.put("height", keyboardHeight) + + val visibleWindowSize = JSONObject() + visibleWindowSize.put("width", screenWidth) + visibleWindowSize.put("height", screenHeight) + + val data = JSONObject() + data.put("visible", isKeyboardVisible) + data.put("keyboardWindowSize", keyboardWindowSize) + data.put("visibleWindowSize", visibleWindowSize) + + return data + } +} \ No newline at end of file diff --git a/app/src/main/java/co/median/android/LaunchActivity.java b/app/src/main/java/co/median/android/LaunchActivity.java new file mode 100644 index 0000000..c4385fb --- /dev/null +++ b/app/src/main/java/co/median/android/LaunchActivity.java @@ -0,0 +1,4 @@ +package co.median.android; + +public class LaunchActivity extends MainActivity{ +} diff --git a/app/src/main/java/co/median/android/LoginManager.java b/app/src/main/java/co/median/android/LoginManager.java new file mode 100644 index 0000000..19f51cf --- /dev/null +++ b/app/src/main/java/co/median/android/LoginManager.java @@ -0,0 +1,137 @@ +package co.median.android; + +import android.content.Context; +import android.os.AsyncTask; + +import org.json.JSONObject; + +import java.lang.ref.WeakReference; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.Observable; +import java.util.regex.Pattern; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; + +/** + * Created by weiyin on 3/16/14. + */ +public class LoginManager extends Observable { + private static final String TAG = LoginManager.class.getName(); + + private Context context; + private CheckRedirectionTask task = null; + + private boolean loggedIn = false; + + LoginManager(Context context) { + this.context = context; + checkLogin(); + } + + public void checkLogin() { + if (task != null) + task.cancel(true); + + String loginDetectionUrl = AppConfig.getInstance(context).loginDetectionUrl; + if (loginDetectionUrl == null) { + return; + } + + task = new CheckRedirectionTask(this); + task.execute(AppConfig.getInstance(context).loginDetectionUrl); + } + + public boolean isLoggedIn() { + return loggedIn; + } + + + private static class CheckRedirectionTask extends AsyncTask { + private WeakReference loginManagerReference; + + public CheckRedirectionTask(LoginManager loginManager) { + this.loginManagerReference = new WeakReference<>(loginManager); + } + + @Override + protected String doInBackground(String... urls){ + LoginManager loginManager = loginManagerReference.get(); + if (loginManager == null) return null; + + try { + URL parsedUrl = new URL(urls[0]); + HttpURLConnection connection = null; + boolean wasRedirected; + int numRedirects = 0; + do { + if (connection != null) + connection.disconnect(); + + connection = (HttpURLConnection) parsedUrl.openConnection(); + connection.setInstanceFollowRedirects(true); + connection.setRequestProperty("User-Agent", AppConfig.getInstance(loginManager.context).userAgent); + + connection.connect(); + int responseCode = connection.getResponseCode(); + + if (responseCode == HttpURLConnection.HTTP_MOVED_PERM || + responseCode == HttpURLConnection.HTTP_MOVED_TEMP) { + wasRedirected = true; + parsedUrl = new URL(parsedUrl, connection.getHeaderField("Location")); + numRedirects++; + } else { + wasRedirected = false; + } + } while (!isCancelled() && wasRedirected && numRedirects < 10); + + String finalUrl = connection.getURL().toString(); + connection.disconnect(); + return finalUrl; + + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + return null; + } + } + + @Override + protected void onPostExecute(String finalUrl) { + LoginManager loginManager = loginManagerReference.get(); + if (loginManager == null) return; + + UrlInspector.getInstance().inspectUrl(finalUrl); + String loginStatus; + + if (finalUrl == null) { + loginManager.loggedIn = false; + loginStatus = "default"; + loginManager.setChanged(); + loginManager.notifyObservers(); + return; + } + + // iterate through loginDetectionRegexes + AppConfig appConfig = AppConfig.getInstance(loginManager.context); + + List regexes = appConfig.loginDetectRegexes; + for (int i = 0; i < regexes.size(); i++) { + Pattern regex = regexes.get(i); + if (regex.matcher(finalUrl).matches()) { + JSONObject entry = appConfig.loginDetectLocations.get(i); + loginManager.loggedIn = entry.optBoolean("loggedIn", false); + + loginStatus = AppConfig.optString(entry, "menuName"); + if (loginStatus == null) loginStatus = loginManager.loggedIn ? "loggedIn" : "default"; + + loginManager.setChanged(); + loginManager.notifyObservers(); + break; + } + } + } + + } +} diff --git a/app/src/main/java/co/median/android/MainActivity.java b/app/src/main/java/co/median/android/MainActivity.java new file mode 100644 index 0000000..817a5ef --- /dev/null +++ b/app/src/main/java/co/median/android/MainActivity.java @@ -0,0 +1,2659 @@ +package co.median.android; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.PorterDuff; +import android.hardware.SensorManager; +import android.location.LocationManager; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Parcel; +import android.preference.PreferenceManager; +import android.provider.Settings; +import android.telephony.PhoneStateListener; +import android.telephony.SignalStrength; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.webkit.CookieManager; +import android.webkit.JavascriptInterface; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.widget.ExpandableListView; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.ActionBarDrawerToggle; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.widget.Toolbar; +import androidx.browser.customtabs.CustomTabColorSchemeParams; +import androidx.browser.customtabs.CustomTabsIntent; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.splashscreen.SplashScreen; +import androidx.core.view.GravityCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.SavedStateHandle; +import androidx.lifecycle.SavedStateViewModelFactory; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.webkit.WebSettingsCompat; +import androidx.webkit.WebViewFeature; + +import com.google.android.material.progressindicator.CircularProgressIndicator; +import com.squareup.seismic.ShakeDetector; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.CookieHandler; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Observable; +import java.util.Observer; +import java.util.Stack; +import java.util.UUID; +import java.util.regex.Pattern; + +import co.median.android.files.CapturedImageSaver; +import co.median.android.widget.GoNativeDrawerLayout; +import co.median.android.widget.GoNativeSwipeRefreshLayout; +import co.median.android.widget.SwipeHistoryNavigationLayout; +import co.median.android.widget.WebViewContainerView; +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; +import co.median.median_core.GoNativeActivity; +import co.median.median_core.GoNativeWebviewInterface; +import co.median.median_core.LeanUtils; +import co.median.median_core.IOUtils; + +public class MainActivity extends AppCompatActivity implements Observer, + GoNativeActivity, + GoNativeSwipeRefreshLayout.OnRefreshListener, + ShakeDetector.Listener, + ShakeDialogFragment.ShakeDialogListener { + public static final String BROADCAST_RECEIVER_ACTION_WEBVIEW_LIMIT_REACHED = "io.gonative.android.MainActivity.Extra.BROADCAST_RECEIVER_ACTION_WEBVIEW_LIMIT_REACHED"; + private static final String webviewDatabaseSubdir = "webviewDatabase"; + private static final String TAG = MainActivity.class.getName(); + public static final String INTENT_TARGET_URL = "targetUrl"; + public static final String EXTRA_WEBVIEW_WINDOW_OPEN = "io.gonative.android.MainActivity.Extra.WEBVIEW_WINDOW_OPEN"; + public static final String EXTRA_NEW_ROOT_URL = "newRootUrl"; + public static final String EXTRA_EXCESS_WINDOW_ID = "excessWindowId"; + public static final String EXTRA_IGNORE_INTERCEPT_MAXWINDOWS = "ignoreInterceptMaxWindows"; + public static final int REQUEST_SELECT_FILE = 100; + private static final int REQUEST_PERMISSION_READ_EXTERNAL_STORAGE = 101; + private static final int REQUEST_PERMISSION_GEOLOCATION = 102; + private static final int REQUEST_PERMISSION_WRITE_EXTERNAL_STORAGE = 103; + private static final int REQUEST_PERMISSION_GENERIC = 199; + private static final int REQUEST_WEBFORM = 300; + public static final int REQUEST_WEB_ACTIVITY = 400; + public static final int GOOGLE_SIGN_IN = 500; + private static final String ON_RESUME_CALLBACK = "median_app_resumed"; + private static final String ON_RESUME_CALLBACK_GN = "gonative_app_resumed"; + private static final String ON_RESUME_CALLBACK_NPM = "_median_app_resumed"; + private static final String CALLBACK_APP_BROWSER_CLOSED = "median_appbrowser_closed"; + + private static final String SAVED_STATE_ACTIVITY_ID = "activityId"; + private static final String SAVED_STATE_IS_ROOT = "isRoot"; + private static final String SAVED_STATE_URL_LEVEL = "urlLevel"; + private static final String SAVED_STATE_PARENT_URL_LEVEL = "parentUrlLevel"; + private static final String SAVED_STATE_SCROLL_X = "scrollX"; + private static final String SAVED_STATE_SCROLL_Y = "scrollY"; + private static final String SAVED_STATE_WEBVIEW_STATE = "webViewState"; + private static final String SAVED_STATE_IGNORE_THEME_SETUP = "ignoreThemeSetup"; + + private boolean isActivityPaused = false; + + private WebViewContainerView mWebviewContainer; + private GoNativeWebviewInterface mWebview; + boolean isPoolWebview = false; + private Stack backHistory = new Stack<>(); + + private View webviewOverlay; + private String initialUrl; + private boolean sidebarNavigationEnabled = true; + + private ValueCallback mUploadMessage; + private ValueCallback uploadMessageLP; + private Uri directUploadImageUri; + private GoNativeDrawerLayout mDrawerLayout; + private View mDrawerView; + private ExpandableListView mDrawerList; + private CircularProgressIndicator mProgress; + private MySwipeRefreshLayout swipeRefreshLayout; + private SwipeHistoryNavigationLayout swipeNavLayout; + private RelativeLayout fullScreenLayout; + private JsonMenuAdapter menuAdapter = null; + private ActionBarDrawerToggle mDrawerToggle; + private ConnectivityManager cm = null; + private ProfilePicker profilePicker = null; + private TabManager tabManager; + private ActionManager actionManager; + private boolean isRoot; + private boolean webviewIsHidden = false; + private Handler handler = new Handler(); + private float hideWebviewAlpha = 0.0f; + private boolean isFirstHideWebview = false; + private Menu mOptionsMenu; + private String activityId; + + private final Runnable statusChecker = new Runnable() { + @Override + public void run() { + runOnUiThread(() -> checkReadyStatus()); + handler.postDelayed(statusChecker, 100); // 0.1 sec + } + }; + private ShakeDetector shakeDetector = new ShakeDetector(this); + private FileDownloader fileDownloader; + private FileWriterSharer fileWriterSharer; + private LoginManager loginManager; + private RegistrationManager registrationManager; + private ConnectivityChangeReceiver connectivityReceiver; + private KeyboardManager keyboardManager; + private BroadcastReceiver navigationTitlesChangedReceiver; + private BroadcastReceiver navigationLevelsChangedReceiver; + private BroadcastReceiver webviewLimitReachedReceiver; + private boolean startedLoading = false; // document readystate checke + protected String postLoadJavascript; + protected String postLoadJavascriptForRefresh; + private StackpreviousWebviewStates; + private GeolocationPermissionCallback geolocationPermissionCallback; + private ArrayList pendingPermissionRequests = new ArrayList<>(); + private ArrayList pendingStartActivityAfterPermissions = new ArrayList<>(); + private String connectivityCallback; + private String connectivityOnceCallback; + private PhoneStateListener phoneStateListener; + private SignalStrength latestSignalStrength; + private boolean restoreBrightnessOnNavigation = false; + private ActivityResultLauncher requestPermissionLauncher; + private ActivityResultLauncher appBrowserActivityLauncher; + private String deviceInfoCallback = ""; + private boolean flagThemeConfigurationChange = false; + private boolean isContentReady; + private String launchSource; + private SavedStateViewModel vm; + + public static class SavedStateViewModel extends ViewModel { + private SavedStateHandle state; + + public SavedStateViewModel(SavedStateHandle savedStateHandle) { + state = savedStateHandle; + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + final AppConfig appConfig = AppConfig.getInstance(this); + GoNativeApplication application = (GoNativeApplication)getApplication(); + GoNativeWindowManager windowManager = application.getWindowManager(); + + this.isRoot = getIntent().getBooleanExtra("isRoot", true); + // Splash events + if (this.isRoot) { + SplashScreen.installSplashScreen(this); + + // remove splash after 7 seconds + new Handler(Looper.getMainLooper()).postDelayed(this::removeSplashWithAnimation, 7000); + } + + this.launchSource = getIntent().getStringExtra("source"); + this.launchSource = TextUtils.isEmpty(this.launchSource) ? "default" : this.launchSource; + + if(appConfig.androidFullScreen){ + toggleFullscreen(true); + } + // must be done AFTER toggleFullScreen to force screen orientation + setScreenOrientationPreference(); + + if (appConfig.keepScreenOn) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + this.hideWebviewAlpha = appConfig.hideWebviewAlpha; + + // App theme setup + ConfigPreferences configPreferences = new ConfigPreferences(this); + String appTheme = configPreferences.getAppTheme(); + + if (TextUtils.isEmpty(appTheme)) { + if (!TextUtils.isEmpty(appConfig.androidTheme)) { + appTheme = appConfig.androidTheme; + } else { + appTheme = "light"; // default is 'light' to support apps with no night assets provided + } + configPreferences.setAppTheme(appTheme); + } + + boolean ignoreThemeUpdate = false; + if (savedInstanceState != null) { + ignoreThemeUpdate = savedInstanceState.getBoolean(SAVED_STATE_IGNORE_THEME_SETUP, false); + } + + if (ignoreThemeUpdate) { + // Ignore app theme setup cause its already called from function setupAppTheme() + Log.d(TAG, "onCreate: configuration change from setupAppTheme(), ignoring theme setup"); + } else { + if ("light".equals(appTheme)) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + } else if ("dark".equals(appTheme)) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + } else if ("auto".equals(appTheme)) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } else { + // default + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + configPreferences.setAppTheme("light"); + } + } + + super.onCreate(savedInstanceState); + + this.activityId = UUID.randomUUID().toString(); + int urlLevel = getIntent().getIntExtra("urlLevel", -1); + int parentUrlLevel = getIntent().getIntExtra("parentUrlLevel", -1); + + if (savedInstanceState != null) { + this.activityId = savedInstanceState.getString(SAVED_STATE_ACTIVITY_ID, activityId); + this.isRoot = savedInstanceState.getBoolean(SAVED_STATE_IS_ROOT, isRoot); + urlLevel = savedInstanceState.getInt(SAVED_STATE_URL_LEVEL, urlLevel); + parentUrlLevel = savedInstanceState.getInt(SAVED_STATE_PARENT_URL_LEVEL, parentUrlLevel); + } + + windowManager.addNewWindow(activityId, isRoot); + windowManager.setUrlLevels(activityId, urlLevel, parentUrlLevel); + + if (appConfig.maxWindowsEnabled) { + windowManager.setIgnoreInterceptMaxWindows(activityId, getIntent().getBooleanExtra(EXTRA_IGNORE_INTERCEPT_MAXWINDOWS, false)); + } + + if (isRoot) { + initialRootSetup(); + } + + this.loginManager = application.getLoginManager(); + + this.fileWriterSharer = new FileWriterSharer(this); + this.fileDownloader = new FileDownloader(this); + + // webview pools + application.getWebViewPool().init(this); + + cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); + + setContentView(R.layout.activity_gonative); + application.mBridge.onActivityCreate(this, isRoot); + + final ViewGroup content = findViewById(android.R.id.content); + content.getViewTreeObserver().addOnPreDrawListener( + new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + // Check whether the initial data is ready. + if (isContentReady || !isRoot) { + // The content is ready. Start drawing. + content.getViewTreeObserver().removeOnPreDrawListener(this); + return true; + } else { + // The content isn't ready. Suspend. + return false; + } + } + }); + + mProgress = findViewById(R.id.progress); + this.fullScreenLayout = findViewById(R.id.fullscreen); + + swipeRefreshLayout = findViewById(R.id.swipe_refresh); + swipeRefreshLayout.setEnabled(appConfig.pullToRefresh); + swipeRefreshLayout.setOnRefreshListener(this); + swipeRefreshLayout.setCanChildScrollUpCallback(() -> mWebview.getWebViewScrollY() > 0); + + if (isAndroidGestureEnabled()) { + appConfig.swipeGestures = false; + } + swipeNavLayout = findViewById(R.id.swipe_history_nav); + swipeNavLayout.setEnabled(appConfig.swipeGestures); + swipeNavLayout.setSwipeNavListener(new SwipeHistoryNavigationLayout.OnSwipeNavListener() { + @Override + public boolean canSwipeLeftEdge() { + if (mWebview.getMaxHorizontalScroll() > 0) { + if (mWebview.getScrollX() > 0) return false; + } + return canGoBack(); + } + + @Override + public boolean canSwipeRightEdge() { + if (mWebview.getMaxHorizontalScroll() > 0) { + if (mWebview.getScrollX() < mWebview.getMaxHorizontalScroll()) return false; + } + return canGoForward(); + } + + @NonNull + @Override + public String getGoBackLabel() { + return ""; + } + + @Override + public boolean navigateBack() { + if (appConfig.swipeGestures && canGoBack()) { + goBack(); + return true; + } + return false; + } + + @Override + public boolean navigateForward() { + if (appConfig.swipeGestures && canGoForward()) { + goForward(); + return true; + } + return false; + } + + @Override + public void leftSwipeReachesLimit() { + + } + + @Override + public void rightSwipeReachesLimit() { + + } + + @Override + public boolean isSwipeEnabled() { + return appConfig.swipeGestures; + } + }); + + swipeRefreshLayout.setColorSchemeColors(getResources().getColor(R.color.pull_to_refresh_color)); + swipeNavLayout.setActiveColor(getResources().getColor(R.color.pull_to_refresh_color)); + swipeRefreshLayout.setProgressBackgroundColorSchemeColor(getResources().getColor(R.color.swipe_nav_background)); + swipeNavLayout.setBackgroundColor(getResources().getColor(R.color.swipe_nav_background)); + + this.webviewOverlay = findViewById(R.id.webviewOverlay); + this.mWebviewContainer = this.findViewById(R.id.webviewContainer); + this.mWebview = this.mWebviewContainer.getWebview(); + this.mWebviewContainer.setupWebview(this, isRoot); + setupWebviewTheme(appTheme); + + boolean isWebViewStateRestored = false; + + // Restore WebView state + SavedStateViewModelFactory factory = new SavedStateViewModelFactory(getApplication(), this); + vm = new ViewModelProvider(this, factory).get(SavedStateViewModel.class); + if (vm.state.contains(SAVED_STATE_WEBVIEW_STATE)) { + Bundle webViewStateBundle = vm.state.get(SAVED_STATE_WEBVIEW_STATE); + if (webViewStateBundle != null) { + // Restore page and history + mWebview.restoreStateFromBundle(webViewStateBundle); + isWebViewStateRestored = true; + } + } + + // Restore scroll state + if(savedInstanceState != null) { + // Restore scroll position (if exists) + int scrollX = savedInstanceState.getInt(SAVED_STATE_SCROLL_X, 0); + int scrollY = savedInstanceState.getInt(SAVED_STATE_SCROLL_Y, 0); + mWebview.scrollTo(scrollX, scrollY); + } + + // profile picker + if (isRoot && (appConfig.showActionBar || appConfig.showNavigationMenu)) { + setupProfilePicker(); + } + + // proxy cookie manager for httpUrlConnection (syncs to webview cookies) + CookieHandler.setDefault(new WebkitCookieManagerProxy()); + + + this.postLoadJavascript = getIntent().getStringExtra("postLoadJavascript"); + this.postLoadJavascriptForRefresh = this.postLoadJavascript; + + this.previousWebviewStates = new Stack<>(); + + // tab navigation + this.tabManager = new TabManager(this, findViewById(R.id.bottom_navigation)); + tabManager.showTabs(false); + + Toolbar toolbar = findViewById(R.id.toolbar); + // Add action bar if getSupportActionBar() is null + // regardless of appConfig.showActionBar value to setup drawers, sidenav + if (getSupportActionBar() == null) { + // Set Material Toolbar as Action Bar. + setSupportActionBar(toolbar); + } + // Hide action bar if showActionBar is FALSE and showNavigationMenu is FALSE + if (!appConfig.showActionBar && !appConfig.showNavigationMenu) { + getSupportActionBar().hide(); + } + + if (!appConfig.showLogoInSideBar && !appConfig.showAppNameInSideBar) { + RelativeLayout headerLayout = findViewById(R.id.header_layout); + if (headerLayout != null) { + headerLayout.setVisibility(View.GONE); + } + } + + if (!appConfig.showLogoInSideBar) { + ImageView appIcon = findViewById(R.id.app_logo); + if (appIcon != null) { + appIcon.setVisibility(View.GONE); + } + } + TextView appName = findViewById(R.id.app_name); + if (appName != null) { + if(appConfig.showAppNameInSideBar) { + appName.setText(appConfig.appName); + } else { + appName.setVisibility(View.INVISIBLE); + } + } + + // actions in action bar + this.actionManager = new ActionManager(this); + this.actionManager.setupActionBar(isRoot); + + // overflow menu icon color + if (toolbar!= null && toolbar.getOverflowIcon() != null) { + toolbar.getOverflowIcon().setColorFilter(getResources().getColor(R.color.titleTextColor), PorterDuff.Mode.SRC_ATOP); + } + + // load url + String url; + + if (isWebViewStateRestored) { + // WebView already has loaded URL when function mWebview.restoreStateFromBundle() was called + url = mWebview.getUrl(); + } else { + Intent intent = getIntent(); + url = getUrlFromIntent(intent); + + if (url == null && isRoot) url = appConfig.getInitialUrl(); + // url from intent (hub and spoke nav) + if (url == null) url = intent.getStringExtra("url"); + + if (url != null) { + + // let plugins add query params to url before loading to WebView + Map queries = application.mBridge.getInitialUrlQueryItems(this, isRoot); + if (queries != null && !queries.isEmpty()) { + Uri.Builder builder = Uri.parse(url).buildUpon(); + for (Map.Entry entry : queries.entrySet()) { + builder.appendQueryParameter(entry.getKey(), entry.getValue()); + } + url = builder.build().toString(); + } + + this.initialUrl = url; + this.mWebview.loadUrl(url); + } else if (intent.getBooleanExtra(EXTRA_WEBVIEW_WINDOW_OPEN, false)) { + // no worries, loadUrl will be called when this new web view is passed back to the message + } else { + GNLog.getInstance().logError(TAG, "No url specified for MainActivity"); + } + } + + showNavigationMenu(isRoot && appConfig.showNavigationMenu); + + actionManager.setupTitleDisplayForUrl(url); + + ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> { + // fix system navigation blocking bottom bar + Insets systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) content.getLayoutParams(); + layoutParams.bottomMargin = systemBarInsets.bottom; + + return WindowInsetsCompat.CONSUMED; + }); + + updateStatusBarOverlay(appConfig.enableOverlayInStatusBar); + updateStatusBarStyle(appConfig.statusBarStyle); + + this.keyboardManager = new KeyboardManager(this, content); + + // style sidebar + if (mDrawerView != null) { + mDrawerView.setBackgroundColor(getResources().getColor(R.color.sidebarBackground)); + } + + // respond to navigation titles processed + this.navigationTitlesChangedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (AppConfig.PROCESSED_NAVIGATION_TITLES.equals(intent.getAction())) { + String url = mWebview.getUrl(); + if (url == null) return; + String title = titleForUrl(url); + if (title != null) { + setTitle(title); + } else { + setTitle(R.string.app_name); + } + } + } + }; + LocalBroadcastManager.getInstance(this).registerReceiver(this.navigationTitlesChangedReceiver, + new IntentFilter(AppConfig.PROCESSED_NAVIGATION_TITLES)); + + this.navigationLevelsChangedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (AppConfig.PROCESSED_NAVIGATION_LEVELS.equals(intent.getAction())) { + String url = mWebview.getUrl(); + if (url == null) return; + int level = urlLevelForUrl(url); + setUrlLevel(level); + } + } + }; + LocalBroadcastManager.getInstance(this).registerReceiver(this.navigationLevelsChangedReceiver, + new IntentFilter(AppConfig.PROCESSED_NAVIGATION_LEVELS)); + + this.webviewLimitReachedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BROADCAST_RECEIVER_ACTION_WEBVIEW_LIMIT_REACHED.equals(intent.getAction())) { + + String excessWindowId = intent.getStringExtra(EXTRA_EXCESS_WINDOW_ID); + if (!TextUtils.isEmpty(excessWindowId)) { + if (excessWindowId.equals(activityId)) finish(); + return; + } + + boolean isActivityRoot = getGNWindowManager().isRoot(activityId); + if (!isActivityRoot) { + finish(); + } + } + } + }; + LocalBroadcastManager.getInstance(this).registerReceiver(this.webviewLimitReachedReceiver, + new IntentFilter(BROADCAST_RECEIVER_ACTION_WEBVIEW_LIMIT_REACHED)); + + validateGoogleService(); + + requestPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + runGonativeDeviceInfo(deviceInfoCallback, false); + }); + + appBrowserActivityLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + String callback = LeanUtils.createJsForCallback(CALLBACK_APP_BROWSER_CLOSED, null); + runJavascript(callback); + }); + } + + public String getActivityId() { + return this.activityId; + } + + private void initialRootSetup() { + File databasePath = new File(getCacheDir(), webviewDatabaseSubdir); + if (databasePath.mkdirs()) { + Log.v(TAG, "databasePath " + databasePath.toString() + " exists"); + } + + // url inspector + UrlInspector.getInstance().init(this); + + // Register launch + ConfigUpdater configUpdater = new ConfigUpdater(this); + configUpdater.registerEvent(); + + // registration service + this.registrationManager = ((GoNativeApplication) getApplication()).getRegistrationManager(); + } + + private void setupProfilePicker() { + Spinner profileSpinner = findViewById(R.id.profile_picker); + profilePicker = new ProfilePicker(this, profileSpinner); + + Spinner segmentedSpinner = findViewById(R.id.segmented_control); + new SegmentedController(this, segmentedSpinner); + } + + private void showNavigationMenu(boolean showNavigation) { + AppConfig appConfig = AppConfig.getInstance(this); + // do the list stuff + mDrawerLayout = findViewById(R.id.drawer_layout); + mDrawerView = findViewById(R.id.left_drawer); + mDrawerList = findViewById(R.id.drawer_list); + + if (showNavigation) { + + // unlock drawer + mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); + + // set shadow + mDrawerLayout.setDrawerShadow(R.drawable.drawer_shadow, GravityCompat.START); + + mDrawerToggle = new ActionBarDrawerToggle(this, mDrawerLayout, + R.string.drawer_open, R.string.drawer_close) { + //Called when a drawer has settled in a completely closed state. + public void onDrawerClosed(View view) { + invalidateOptionsMenu(); // creates call to onPrepareOptionsMenu() + mDrawerLayout.setDisableTouch(appConfig.swipeGestures && canGoBack()); + } + + //Called when a drawer has settled in a completely open state. + public void onDrawerOpened(View drawerView) { + invalidateOptionsMenu(); // creates call to onPrepareOptionsMenu() + mDrawerLayout.setDisableTouch(false); + } + }; + + mDrawerToggle.setDrawerIndicatorEnabled(true); + mDrawerToggle.getDrawerArrowDrawable().setColor(getResources().getColor(R.color.titleTextColor)); + + mDrawerLayout.addDrawerListener(mDrawerToggle); + + setupMenu(); + + // update the menu + if (appConfig.loginDetectionUrl != null) { + this.loginManager.addObserver(this); + } + } else { + // lock drawer so it could not be swiped + mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); + } + } + + private String getUrlFromIntent(Intent intent) { + if (intent == null) return null; + // first check intent in case it was created from push notification + String targetUrl = intent.getStringExtra(INTENT_TARGET_URL); + if (targetUrl != null && !targetUrl.isEmpty()){ + return targetUrl; + } + + if (Intent.ACTION_VIEW.equals(intent.getAction())) { + Uri uri = intent.getData(); + if (uri != null && (uri.getScheme().endsWith(".http") || uri.getScheme().endsWith(".https"))) { + Uri.Builder builder = uri.buildUpon(); + if (uri.getScheme().endsWith(".https")) { + builder.scheme("https"); + } else if (uri.getScheme().endsWith(".http")) { + builder.scheme("http"); + } + return builder.build().toString(); + } else { + return intent.getDataString(); + } + } + + return null; + } + + protected void onPause() { + super.onPause(); + GoNativeApplication application = (GoNativeApplication)getApplication(); + application.mBridge.onActivityPause(this); + this.isActivityPaused = true; + stopCheckingReadyStatus(); + + if (application.mBridge.pauseWebViewOnActivityPause()) { + this.mWebview.onPause(); + } + + // unregister connectivity + if (this.connectivityReceiver != null) { + unregisterReceiver(this.connectivityReceiver); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + CookieManager.getInstance().flush(); + } + + shakeDetector.stop(); + } + + @Override + protected void onStart() { + super.onStart(); + GoNativeApplication application = (GoNativeApplication)getApplication(); + application.mBridge.onActivityStart(this); + if (AppConfig.getInstance(this).enableWebRTCBluetoothAudio) { + AudioUtils.initAudioFocusListener(this); + } + } + + @Override + protected void onResume() { + super.onResume(); + GoNativeApplication application = (GoNativeApplication)getApplication(); + application.setAppBackgrounded(false); + application.mBridge.onActivityResume(this); + this.mWebview.onResume(); + + AppConfig appConfig = AppConfig.getInstance(this); + + if (isActivityPaused) { + this.isActivityPaused = false; + if (appConfig.injectMedianJS) { + runJavascript(LeanUtils.createJsForCallback(ON_RESUME_CALLBACK, null)); + runJavascript(LeanUtils.createJsForCallback(ON_RESUME_CALLBACK_GN, null)); + } else { + runJavascript(LeanUtils.createJsForCallback(ON_RESUME_CALLBACK_NPM, null)); + } + } + + retryFailedPage(); + // register to listen for connectivity changes + this.connectivityReceiver = new ConnectivityChangeReceiver(); + registerReceiver(this.connectivityReceiver, + new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + + // check login status + this.loginManager.checkLogin(); + + if (appConfig.shakeToClearCache) { + SensorManager sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE); + shakeDetector.setSensitivity(ShakeDetector.SENSITIVITY_HARD); + shakeDetector.start(sensorManager); + } + } + + @Override + protected void onStop() { + super.onStop(); + GoNativeApplication application = (GoNativeApplication)getApplication(); + application.mBridge.onActivityStop(this); + if (isRoot) { + if (AppConfig.getInstance(this).clearCache) { + this.mWebview.clearCache(true); + } + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + GoNativeApplication application = (GoNativeApplication)getApplication(); + application.mBridge.onActivityDestroy(this); + application.getWindowManager().removeWindow(activityId); + + if (fileDownloader != null) fileDownloader.unbindDownloadService(); + + // destroy webview + if (this.mWebview != null) { + this.mWebview.stopLoading(); + // must remove from view hierarchy to destroy + ViewGroup parent = (ViewGroup) this.mWebview.getParent(); + if (parent != null) { + parent.removeView((View)this.mWebview); + } + if (!this.isPoolWebview) this.mWebview.destroy(); + } + + this.loginManager.deleteObserver(this); + + if (this.navigationTitlesChangedReceiver != null) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(this.navigationTitlesChangedReceiver); + } + if (this.navigationLevelsChangedReceiver != null) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(this.navigationLevelsChangedReceiver); + } + if (this.webviewLimitReachedReceiver != null) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(this.webviewLimitReachedReceiver); + } + } + + @Override + public void onSubscriptionChanged() { + if (registrationManager == null) return; + registrationManager.subscriptionInfoChanged(); + } + + @Override + public void launchNotificationActivity(String extra) { + Intent mainIntent = new Intent(this, MainActivity.class); + mainIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + if (extra != null && !extra.isEmpty()) { + mainIntent.putExtra(INTENT_TARGET_URL, extra); + } + + startActivity(mainIntent); + } + + private void retryFailedPage() { + // skip if webview is currently loading + if (this.mWebview.getProgress() < 100) return; + + // skip if webview has a page loaded + String currentUrl = this.mWebview.getUrl(); + if (currentUrl != null && !currentUrl.equals(UrlNavigation.OFFLINE_PAGE_URL)) return; + + // skip if there is nothing in history + if (this.backHistory.isEmpty()) return; + + // skip if no network connectivity + if (this.isDisconnected()) return; + + // finally, retry loading the page + this.loadUrl(this.backHistory.pop()); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + // Saves current WebView's history and URL or loaded page state to a new bundle + Bundle webViewOutState = new Bundle(); + mWebview.saveStateToBundle(webViewOutState); + // Initialize ViewModel for WebView state/configuration changes + SavedStateViewModelFactory factory = new SavedStateViewModelFactory(getApplication(), this); + vm = new ViewModelProvider(this, factory).get(SavedStateViewModel.class); + // Save the new WebView state bundle to a ViewModel + vm.state.set(SAVED_STATE_WEBVIEW_STATE, webViewOutState); + + // Save other WebView data to the bundle + outState.putString(SAVED_STATE_ACTIVITY_ID, activityId); + outState.putBoolean(SAVED_STATE_IS_ROOT, getGNWindowManager().isRoot(activityId)); + outState.putInt(SAVED_STATE_URL_LEVEL, getGNWindowManager().getUrlLevel(activityId)); + outState.putInt(SAVED_STATE_PARENT_URL_LEVEL, getGNWindowManager().getParentUrlLevel(activityId)); + outState.putInt(SAVED_STATE_SCROLL_X, mWebview.getWebViewScrollX()); + outState.putInt(SAVED_STATE_SCROLL_Y, mWebview.getWebViewScrollY()); + if (flagThemeConfigurationChange) { + outState.putBoolean(SAVED_STATE_IGNORE_THEME_SETUP, true); + } + + super.onSaveInstanceState(outState); + } + + public void addToHistory(String url) { + if (url == null) return; + + if (this.backHistory.isEmpty() || !this.backHistory.peek().equals(url)) { + this.backHistory.push(url); + } + + checkNavigationForPage(url); + + // this is a little hack to show the webview after going back in history in single-page apps. + // We may never get onPageStarted or onPageFinished, hence the webview would be forever + // hidden when navigating back in single-page apps. We do, however, get an updatedHistory callback. + showWebview(0.3); + } + + @Override + public void hearShake() { + String FRAGMENT_TAG = "ShakeDialogFragment"; + if (getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG) != null) { + return; + } + + ShakeDialogFragment dialog = new ShakeDialogFragment(); + dialog.show(getSupportFragmentManager(), FRAGMENT_TAG); + } + + @Override + public void onClearCache(DialogFragment dialog) { + clearWebviewCache(); + Toast.makeText(this, R.string.cleared_cache, Toast.LENGTH_SHORT).show(); + } + + public boolean canGoBack() { + if (this.mWebview == null) return false; + return this.mWebview.canGoBack(); + } + + public void goBack() { + if (this.mWebview == null) return; + if (LeanWebView.isCrosswalk()) { + // not safe to do for non-crosswalk, as we may never get a page finished callback + // for single-page apps + hideWebview(); + } + + this.mWebview.goBack(); + } + + private boolean canGoForward() { + return this.mWebview.canGoForward(); + } + + private void goForward() { + if (LeanWebView.isCrosswalk()) { + // not safe to do for non-crosswalk, as we may never get a page finished callback + // for single-page apps + hideWebview(); + } + + this.mWebview.goForward(); + } + + @Override + public void sharePage(String optionalUrl, String optionalText) { + String shareUrl; + String currentUrl = this.mWebview.getUrl(); + if (TextUtils.isEmpty(optionalUrl)) { + shareUrl = currentUrl; + } else { + try { + java.net.URI optionalUri = new java.net.URI(optionalUrl); + if (optionalUri.isAbsolute()) { + shareUrl = optionalUrl; + } else { + java.net.URI currentUri = new java.net.URI(currentUrl); + shareUrl = currentUri.resolve(optionalUri).toString(); + } + } catch (URISyntaxException e) { + shareUrl = optionalUrl; + } + } + + if (TextUtils.isEmpty(shareUrl)) return; + + String shareData = TextUtils.isEmpty(optionalText) ? shareUrl : optionalText + System.lineSeparator() + shareUrl; + + Intent share = new Intent(Intent.ACTION_SEND); + share.setType("text/plain"); + share.putExtra(Intent.EXTRA_TEXT, shareData); + startActivity(Intent.createChooser(share, getString(R.string.action_share))); + } + + private void logout() { + this.mWebview.stopLoading(); + + // log out by clearing all cookies and going to home page + clearWebviewCookies(); + + updateMenu(false); + this.loginManager.checkLogin(); + this.mWebview.loadUrl(AppConfig.getInstance(this).getInitialUrl()); + } + + public void loadUrl(String url) { + loadUrl(url, false); + } + + public void loadUrl(String url, boolean isFromTab) { + if (url == null) return; + + this.postLoadJavascript = null; + this.postLoadJavascriptForRefresh = null; + + if (url.equalsIgnoreCase("median_logout") || url.equalsIgnoreCase("gonative_logout")) + logout(); + else + this.mWebview.loadUrl(url); + + if (!isFromTab && this.tabManager != null) this.tabManager.selectTab(url, null); + } + + public void loadUrlAndJavascript(String url, String javascript) { + loadUrlAndJavascript(url, javascript, false); + } + + public void loadUrlAndJavascript(String url, String javascript, boolean isFromTab) { + String currentUrl = this.mWebview.getUrl(); + + if (url != null && currentUrl != null && url.equals(currentUrl)) { + runJavascript(javascript); + this.postLoadJavascriptForRefresh = javascript; + } else { + this.postLoadJavascript = javascript; + this.postLoadJavascriptForRefresh = javascript; + this.mWebview.loadUrl(url); + } + + if (!isFromTab && this.tabManager != null) this.tabManager.selectTab(url, javascript); + } + + public void runJavascript(String javascript) { + if (javascript == null) return; + this.mWebview.runJavascript(javascript); + } + + public boolean isDisconnected(){ + NetworkInfo ni = cm.getActiveNetworkInfo(); + return ni == null || !ni.isConnected(); + } + + @Override + public void clearWebviewCache() { + mWebview.clearCache(true); + } + + @Override + public void clearWebviewCookies() { + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.removeAllCookies(aBoolean -> Log.d(TAG, "clearWebviewCookies: onReceiveValue callback: " + aBoolean)); + AsyncTask.THREAD_POOL_EXECUTOR.execute(cookieManager::flush); + } + + @Override + public void hideWebview() { + GoNativeApplication application = (GoNativeApplication)getApplication(); + application.mBridge.onHideWebview(this); + + if (AppConfig.getInstance(this).disableAnimations) return; + + this.webviewIsHidden = true; + mProgress.setAlpha(1.0f); + mProgress.setVisibility(View.VISIBLE); + + if (this.isFirstHideWebview) { + this.webviewOverlay.setAlpha(1.0f); + } else { + this.webviewOverlay.setAlpha(1 - this.hideWebviewAlpha); + } + + showWebview(10); + } + + private void showWebview(double delay) { + if (delay > 0) { + handler.postDelayed(this::showWebview, (int) (delay * 1000)); + } else { + showWebview(); + } + } + + // shows webview with no animation + public void showWebviewImmediately() { + this.isFirstHideWebview = false; + webviewIsHidden = false; + startedLoading = false; + stopCheckingReadyStatus(); + this.webviewOverlay.setAlpha(0.0f); + this.mProgress.setVisibility(View.INVISIBLE); + } + + + @Override + public void showWebview() { + this.isFirstHideWebview = false; + startedLoading = false; + + if (!webviewIsHidden) { + // don't animate if already visible + mProgress.setVisibility(View.INVISIBLE); + return; + } + + webviewIsHidden = false; + + webviewOverlay.animate().alpha(0.0f) + .setDuration(300) + .setStartDelay(150); + + mProgress.animate().alpha(0.0f) + .setDuration(60); + } + + public void updatePageTitle() { + if (AppConfig.getInstance(this).useWebpageTitle) { + setTitle(this.mWebview.getTitle()); + } + } + + public void update (Observable sender, Object data) { + if (sender instanceof LoginManager) { + updateMenu(((LoginManager) sender).isLoggedIn()); + } + } + + @Override + public void updateMenu(){ + this.loginManager.checkLogin(); + } + + private void updateMenu(boolean isLoggedIn){ + if (menuAdapter == null) + setupMenu(); + + try { + if (isLoggedIn) + menuAdapter.update("loggedIn"); + else + menuAdapter.update("default"); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + } + + private boolean isDrawerOpen() { + return mDrawerLayout != null && mDrawerLayout.isDrawerOpen(mDrawerView); + } + + private void setDrawerEnabled(boolean enabled) { + if (!isRoot) return; + + AppConfig appConfig = AppConfig.getInstance(this); + if (!appConfig.showNavigationMenu) return; + + if (mDrawerLayout != null) { + mDrawerLayout.setDrawerLockMode(enabled ? GoNativeDrawerLayout.LOCK_MODE_UNLOCKED : + GoNativeDrawerLayout.LOCK_MODE_LOCKED_CLOSED); + } + + if((sidebarNavigationEnabled || appConfig.showActionBar ) && enabled){ + Toolbar toolbar = findViewById(R.id.toolbar); + if (toolbar != null) { + toolbar.setVisibility(View.VISIBLE); + } + } + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(enabled); + } + } + + private void setupMenu(){ + menuAdapter = new JsonMenuAdapter(this, mDrawerList); + try { + menuAdapter.update("default"); + mDrawerList.setAdapter(menuAdapter); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, "Error setting up menu", e); + } + + mDrawerList.setOnGroupClickListener(menuAdapter); + mDrawerList.setOnChildClickListener(menuAdapter); + } + + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + GoNativeApplication application = (GoNativeApplication)getApplication(); + application.mBridge.onPostCreate(this, savedInstanceState, isRoot); + + // Sync the toggle state after onRestoreInstanceState has occurred. + if (mDrawerToggle != null) + mDrawerToggle.syncState(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + this.actionManager.setupActionBarDisplay(); + + GoNativeApplication application = (GoNativeApplication)getApplication(); + // Pass any configuration change to the drawer toggles + if (mDrawerToggle != null) + mDrawerToggle.onConfigurationChanged(newConfig); +// if (swipeRefreshLayout != null) +// TODO swipeRefreshLayout.onConfigurationChanged(newConfig); + application.mBridge.onConfigurationChange(this); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + GoNativeApplication application = (GoNativeApplication)getApplication(); + application.mBridge.onActivityResult(this, requestCode, resultCode, data); + + if (data != null && data.getBooleanExtra("exit", false)) + finish(); + + String url = null; + boolean success = false; + if (data != null) { + url = data.getStringExtra("url"); + success = data.getBooleanExtra("success", false); + } + + if (requestCode == REQUEST_WEBFORM && resultCode == RESULT_OK) { + if (url != null) + loadUrl(url); + else { + // go to initialURL without login/signup override + this.mWebview.setCheckLoginSignup(false); + this.mWebview.loadUrl(AppConfig.getInstance(this).getInitialUrl()); + } + + if (AppConfig.getInstance(this).showNavigationMenu) { + updateMenu(success); + } + } + + if (requestCode == REQUEST_WEB_ACTIVITY && resultCode == RESULT_OK) { + if (url != null) { + int urlLevel = data.getIntExtra("urlLevel", -1); + int parentUrlLevel = getGNWindowManager().getParentUrlLevel(activityId); + if (urlLevel == -1 || parentUrlLevel == -1 || urlLevel > parentUrlLevel) { + // open in this activity + this.postLoadJavascript = data.getStringExtra("postLoadJavascript"); + loadUrl(url); + } else { + // urlLevel <= parentUrlLevel, so pass up the chain + setResult(RESULT_OK, data); + finish(); + } + } + } + + if (requestCode == REQUEST_SELECT_FILE) { + if (resultCode != RESULT_OK) { + cancelFileUpload(); + return; + } + + // from documents (and video camera) + if (data != null && data.getData() != null) { + if (mUploadMessage != null) { + mUploadMessage.onReceiveValue(data.getData()); + mUploadMessage = null; + } + + if (uploadMessageLP != null) { + uploadMessageLP.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data)); + uploadMessageLP = null; + } + + return; + } + + // we may get clip data for multi-select documents + if (data != null && data.getClipData() != null) { + ClipData clipData = data.getClipData(); + ArrayList files = new ArrayList<>(clipData.getItemCount()); + for (int i = 0; i < clipData.getItemCount(); i++) { + ClipData.Item item = clipData.getItemAt(i); + if (item.getUri() != null) { + files.add(item.getUri()); + } + } + + if (mUploadMessage != null) { + // shouldn never happen, but just in case, send the first item + if (files.size() > 0) { + mUploadMessage.onReceiveValue(files.get(0)); + } else { + mUploadMessage.onReceiveValue(null); + } + mUploadMessage = null; + } + + if (uploadMessageLP != null) { + uploadMessageLP.onReceiveValue(files.toArray(new Uri[files.size()])); + uploadMessageLP = null; + } + + return; + } + + // from camera + if (this.directUploadImageUri != null) { + Uri currentCaptureUri = new CapturedImageSaver().saveCapturedBitmap(this, this.directUploadImageUri); + if (mUploadMessage != null) { + mUploadMessage.onReceiveValue(currentCaptureUri); + mUploadMessage = null; + } + if (uploadMessageLP != null) { + uploadMessageLP.onReceiveValue(new Uri[]{currentCaptureUri}); + uploadMessageLP = null; + } + getContentResolver().delete(this.directUploadImageUri, null, null); + this.directUploadImageUri = null; + + return; + } + + // Should not reach here. + cancelFileUpload(); + } + } + + public void cancelFileUpload() { + if (mUploadMessage != null) { + mUploadMessage.onReceiveValue(null); + mUploadMessage = null; + } + + if (uploadMessageLP != null) { + uploadMessageLP.onReceiveValue(null); + uploadMessageLP = null; + } + + this.directUploadImageUri = null; + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + String url = getUrlFromIntent(intent); + if (url != null && !url.isEmpty()) { + if (!urlEqualsIgnoreSlash(url, mWebview.getUrl())) + loadUrl(url); + return; + } + Log.w(TAG, "Received intent without url"); + + ((GoNativeApplication) getApplication()).mBridge.onActivityNewIntent(this, intent); + } + + private boolean urlEqualsIgnoreSlash(String url1, String url2) { + if (url1 == null || url2 == null) return false; + if (url1.endsWith("/")) { + url1 = url1.substring(0, url1.length() - 1); + } + if (url2.endsWith("/")) { + url2 = url2.substring(0, url2.length() - 1); + } + if (url1.startsWith("http://")) { + url1 = "https://" + url1.substring(7); + } + return url1.equals(url2); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if ((keyCode == KeyEvent.KEYCODE_BACK)) { + if (AppConfig.getInstance(this).disableBackButton) { + return true; + } + + if (this.mWebview.exitFullScreen()) { + return true; + } + + if (isDrawerOpen()){ + mDrawerLayout.closeDrawers(); + return true; + } + else if (canGoBack()) { + goBack(); + return true; + } + else if (!this.previousWebviewStates.isEmpty()) { + Bundle state = previousWebviewStates.pop(); + LeanWebView webview = new LeanWebView(this); + webview.restoreStateFromBundle(state); + switchToWebview(webview, /* isPool */ false, /* isBack */ true); + return true; + } + } + + if (((GoNativeApplication) getApplication()).mBridge.onKeyDown(keyCode, event)) { + return true; + } + + return super.onKeyDown(keyCode, event); + } + + // isPoolWebView is used to keep track of whether we are showing a pooled webview, which has implications + // for page navigation, namely notifying the pool to disown the webview. + // isBack means the webview is being switched in as part of back navigation behavior. If isBack=false, + // then we will save the state of the old one switched out. + public void switchToWebview(GoNativeWebviewInterface newWebview, boolean isPoolWebview, boolean isBack) { + this.mWebviewContainer.setupWebview(this, isRoot); + + // scroll to top + ((View)newWebview).scrollTo(0, 0); + + View prev = (View)this.mWebview; + + if (!isBack) { + // save the state for back button behavior + Bundle stateBundle = new Bundle(); + this.mWebview.saveStateToBundle(stateBundle); + this.previousWebviewStates.add(stateBundle); + } + + // replace the current web view in the parent with the new view + if (newWebview != prev) { + // a view can only have one parent, and attempting to add newWebview if it already has + // a parent will cause a runtime exception. So be extra safe by removing it from its parent. + ViewParent temp = newWebview.getParent(); + if (temp instanceof ViewGroup) { + ((ViewGroup) temp).removeView((View)newWebview); + } + + ViewGroup parent = (ViewGroup) prev.getParent(); + int index = parent.indexOfChild(prev); + parent.removeView(prev); + parent.addView((View) newWebview, index); + ((View)newWebview).setLayoutParams(prev.getLayoutParams()); + + // webviews can still send some extraneous events to this activity if we do not remove + // its callbacks + WebViewSetup.removeCallbacks((LeanWebView) prev); + + if (!this.isPoolWebview) { + ((GoNativeWebviewInterface)prev).destroy(); + } + } + + this.isPoolWebview = isPoolWebview; + this.mWebview = newWebview; + + if (this.postLoadJavascript != null) { + runJavascript(this.postLoadJavascript); + this.postLoadJavascript = null; + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.topmenu, menu); + mOptionsMenu = menu; + + if (this.actionManager != null) { + this.actionManager.addActions(menu); + } + + return true; + } + + public Menu getOptionsMenu () { + return mOptionsMenu; + } + + public void setMenuItemsVisible (boolean visible) { + setMenuItemsVisible(visible, null); + } + + public void setMenuItemsVisible(boolean visible, MenuItem exception) { + + for (int i = 0; i < mOptionsMenu.size(); i++) { + MenuItem item = mOptionsMenu.getItem(i); + if (item == exception) { + continue; + } + + item.setVisible(visible); + item.setEnabled(visible); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // Pass the event to ActionBarDrawerToggle, if it returns + // true, then it has handled the app icon touch event + + if (mDrawerToggle != null) { + if (mDrawerToggle.onOptionsItemSelected(item)) { + return true; + } + } + + // actions + if (this.actionManager != null) { + if (this.actionManager.onOptionsItemSelected(item)) { + return true; + } + } + + // handle other items + if (item.getItemId() == android.R.id.home) { + if (this.actionManager.isOnSearchMode()) { + this.actionManager.closeSearchView(); + this.actionManager.setOnSearchMode(false); + return true; + } + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onRefresh() { + refreshPage(); + stopNavAnimation(true, 1000); + } + + private void stopNavAnimation(boolean isConsumed){ + stopNavAnimation(isConsumed, 100); + } + + private void stopNavAnimation(boolean isConsumed, int delay){ + // let the refreshing spinner stay for a little bit if the native show/hide is disabled + // otherwise there isn't enough of a user confirmation that the page is refreshing + if (isConsumed && AppConfig.getInstance(this).disableAnimations) { + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + swipeRefreshLayout.setRefreshing(false); + } + }, delay); + } else { + this.swipeRefreshLayout.setRefreshing(false); + } + } + + public void refreshPage() { + String url = this.mWebview.getUrl(); + if (url != null && url.equals(UrlNavigation.OFFLINE_PAGE_URL)){ + if (this.mWebview.canGoBack()) { + this.mWebview.goBack(); + } else if (this.initialUrl != null) { + this.mWebview.loadUrl(this.initialUrl); + } + updateMenu(); + } + else { + this.postLoadJavascript = this.postLoadJavascriptForRefresh; + this.mWebview.loadUrl(url); + } + } + + private void removeSplashWithAnimation() { + isContentReady = true; + stopCheckingReadyStatus(); + } + + // onPageFinished + @Override + public void checkNavigationForPage(String url) { + // don't change anything on navigation if the url that just finished was a file download + if (url.equals(this.fileDownloader.getLastDownloadedUrl())) return; + + if (this.tabManager != null) { + this.tabManager.checkTabs(url); + } + + if (this.actionManager != null) { + this.actionManager.checkActions(url); + } + + if (this.registrationManager != null) { + this.registrationManager.checkUrl(url); + } + + if (this.menuAdapter != null) { + this.menuAdapter.autoSelectItem(url); + } + } + + // onPageStarted + @Override + public void checkPreNavigationForPage(String url) { + if (this.tabManager != null) { + this.tabManager.autoSelectTab(url); + } + + if (this.menuAdapter != null) { + this.menuAdapter.autoSelectItem(url); + } + + AppConfig appConfig = AppConfig.getInstance(this); + setDrawerEnabled(appConfig.shouldShowSidebarForUrl(url) && sidebarNavigationEnabled); + + // When current URL canGoBack and swipeGestures are enabled, disable touch events on DrawerLayout + if (this.mDrawerLayout != null && this.mDrawerLayout.getDrawerLockMode(GravityCompat.START) != DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { + mDrawerLayout.setDisableTouch(appConfig.swipeGestures && canGoBack()); + } + } + + public ActionManager getActionManager() { + return this.actionManager; + } + + @Override + public void setupTitleDisplayForUrl(String url) { + if (this.actionManager == null) return; + this.actionManager.setupTitleDisplayForUrl(url); + } + + @Override + public int urlLevelForUrl(String url) { + ArrayList entries = AppConfig.getInstance(this).navStructureLevelsRegex; + if (entries != null) { + for (int i = 0; i < entries.size(); i++) { + Pattern regex = entries.get(i); + if (regex.matcher(url).matches()) { + return AppConfig.getInstance(this).navStructureLevels.get(i); + } + } + } + + // return unknown + return -1; + } + + @Override + public String titleForUrl(String url) { + ArrayList> entries = AppConfig.getInstance(this).navTitles; + String title = null; + + if (entries != null) { + for (HashMap entry : entries) { + Pattern regex = (Pattern)entry.get("regex"); + + if (regex.matcher(url).matches()) { + if (entry.containsKey("title")) { + title = (String)entry.get("title"); + } + } + } + } + + return title; + } + + public void closeDrawers() { + mDrawerLayout.closeDrawers(); + } + + public boolean isNotRoot() { + return !isRoot; + } + + @Override + public int getParentUrlLevel() { + return getGNWindowManager().getParentUrlLevel(activityId); + } + + @Override + public int getUrlLevel() { + return getGNWindowManager().getUrlLevel(activityId); + } + + @Override + public void setUrlLevel(int urlLevel) { + getGNWindowManager().setUrlLevel(activityId, urlLevel); + } + + public ProfilePicker getProfilePicker() { + return profilePicker; + } + + public FileDownloader getFileDownloader() { + return fileDownloader; + } + + public FileWriterSharer getFileWriterSharer() { + return fileWriterSharer; + } + + public StatusCheckerBridge getStatusCheckerBridge() { + return new StatusCheckerBridge(); + } + + @Override + public void setTitle(CharSequence title) { + super.setTitle(title); + if (actionManager != null) { + actionManager.showTextActionBarTitle(title); + } + } + + @Override + public void startCheckingReadyStatus() { + statusChecker.run(); + } + + private void stopCheckingReadyStatus() { + handler.removeCallbacks(statusChecker); + } + + public void checkReadyStatus() { + this.mWebview.runJavascript("if (median_status_checker && typeof median_status_checker.onReadyState === 'function') median_status_checker.onReadyState(document.readyState);"); + } + + private void checkReadyStatusResult(String status) { + // if interactiveDelay is specified, then look for readyState=interactive, and show webview + // with a delay. If not specified, wait for readyState=complete. + double interactiveDelay = AppConfig.getInstance(this).interactiveDelay; + + if (status.equals("loading") || (Double.isNaN(interactiveDelay) && status.equals("interactive"))) { + startedLoading = true; + } else if ((!Double.isNaN(interactiveDelay) && status.equals("interactive")) + || (startedLoading && status.equals("complete"))) { + + if (status.equals("interactive")) { + showWebview(interactiveDelay); + } else { + showWebview(); + } + if (isContentReady) { + stopCheckingReadyStatus(); + } + } + + if (status.equals("complete") || status.equals("interactive")) { + removeSplashWithAnimation(); + } + } + + @Override + public void toggleFullscreen(boolean fullscreen) { + ActionBar actionBar = this.getSupportActionBar(); + View decorView = getWindow().getDecorView(); + int visibility = decorView.getSystemUiVisibility(); + int fullscreenFlags = View.SYSTEM_UI_FLAG_LOW_PROFILE | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; + + if (Build.VERSION.SDK_INT >= 16) { + fullscreenFlags |= View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + } + + if (Build.VERSION.SDK_INT >= 19) { + fullscreenFlags |= View.SYSTEM_UI_FLAG_IMMERSIVE | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + } + + if (fullscreen) { + visibility |= fullscreenFlags; + if (actionBar != null) actionBar.hide(); + } else { + visibility &= ~fullscreenFlags; + if (actionBar != null && AppConfig.getInstance(this).showActionBar) actionBar.show(); + + // Fix for webview keyboard not showing, see https://github.com/mozilla-tw/FirefoxLite/issues/842 + this.mWebview.clearFocus(); + } + + decorView.setSystemUiVisibility(visibility); + + // Full-screen is used for playing videos. + // Allow sensor-based rotation when in full screen (even overriding user rotation preference) + // If orientation is forced landscape don't set sensor based orientation + if (fullscreen && AppConfig.getInstance(this).forceScreenOrientation != AppConfig.ScreenOrientations.LANDSCAPE) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); + } else { + setScreenOrientationPreference(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + ((GoNativeApplication) getApplication()).mBridge.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + switch (requestCode) { + case REQUEST_PERMISSION_GEOLOCATION: + if (this.geolocationPermissionCallback != null) { + if (grantResults.length >= 2 && + grantResults[0] == PackageManager.PERMISSION_GRANTED && + grantResults[1] == PackageManager.PERMISSION_GRANTED) { + this.geolocationPermissionCallback.onResult(true); + } else { + this.geolocationPermissionCallback.onResult(false); + } + this.geolocationPermissionCallback = null; + } + break; + case REQUEST_PERMISSION_GENERIC: + Iterator it = pendingPermissionRequests.iterator(); + while (it.hasNext()) { + PermissionsCallbackPair pair = it.next(); + if (pair.permissions.length != permissions.length) continue; + boolean skip = false; + for (int i = 0; i < pair.permissions.length && i < permissions.length; i++) { + if (!pair.permissions[i].equals(permissions[i])) { + skip = true; + break; + } + } + if (skip) continue; + + // matches PermissionsCallbackPair + if (pair.callback != null) { + pair.callback.onPermissionResult(permissions, grantResults); + } + it.remove(); + } + + if (pendingPermissionRequests.size() == 0 && pendingStartActivityAfterPermissions.size() > 0) { + Iterator i = pendingStartActivityAfterPermissions.iterator(); + while (i.hasNext()) { + Intent intent = i.next(); + startActivity(intent); + i.remove(); + } + } + break; + } + } + + public GoNativeWindowManager getGNWindowManager() { + return ((GoNativeApplication) getApplication()).getWindowManager(); + } + + @Override + public int getWindowCount() { + return getGNWindowManager().getWindowCount(); + } + + public void setUploadMessage(ValueCallback mUploadMessage) { + this.mUploadMessage = mUploadMessage; + } + + public void setUploadMessageLP(ValueCallback uploadMessageLP) { + this.uploadMessageLP = uploadMessageLP; + } + + public void setDirectUploadImageUri(Uri directUploadImageUri) { + this.directUploadImageUri = directUploadImageUri; + } + + public RelativeLayout getFullScreenLayout() { + return fullScreenLayout; + } + + @Override + public GoNativeWebviewInterface getWebView() { + return mWebview; + } + + public class StatusCheckerBridge { + @JavascriptInterface + public void onReadyState(final String state) { + runOnUiThread(() -> checkReadyStatusResult(state)); + } + } + + private class ConnectivityChangeReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + retryFailedPage(); + if (connectivityCallback != null) { + sendConnectivity(connectivityCallback); + } + } + } + + public void getRuntimeGeolocationPermission(final GeolocationPermissionCallback callback) { + if (isLocationPermissionGranted()) { + callback.onResult(true); + } + + if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_FINE_LOCATION) || + ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_COARSE_LOCATION)) { + Toast.makeText(this, R.string.request_permission_explanation_geolocation, Toast.LENGTH_SHORT).show(); + } + + this.geolocationPermissionCallback = callback; + ActivityCompat.requestPermissions(this, new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION + }, REQUEST_PERMISSION_GEOLOCATION); + } + + public void getPermission(String[] permissions, PermissionCallback callback) { + boolean needToRequest = false; + for (String permission : permissions) { + if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { + needToRequest = true; + break; + } + } + + if (needToRequest) { + if (callback != null) { + pendingPermissionRequests.add(new PermissionsCallbackPair(permissions, callback)); + } + + ActivityCompat.requestPermissions(this, permissions, REQUEST_PERMISSION_GENERIC); + } else { + // send all granted result + if (callback != null) { + int[] results = new int[permissions.length]; + for (int i = 0; i < results.length; i++) { + results[i] = PackageManager.PERMISSION_GRANTED; + } + callback.onPermissionResult(permissions, results); + } + } + } + + public void startActivityAfterPermissions(Intent intent) { + if (pendingPermissionRequests.size() == 0) { + startActivity(intent); + } else { + pendingStartActivityAfterPermissions.add(intent); + } + } + + private void setScreenOrientationPreference() { + AppConfig appConfig = AppConfig.getInstance(this); + if (appConfig.forceScreenOrientation != null) { + setDeviceOrientation(appConfig.forceScreenOrientation); + return; + } + + if (getResources().getBoolean(R.bool.isTablet)) { + if (appConfig.tabletScreenOrientation != null) { + setDeviceOrientation(appConfig.tabletScreenOrientation); + return; + } + } else { + if (appConfig.phoneScreenOrientation != null) { + setDeviceOrientation(appConfig.phoneScreenOrientation); + return; + } + } + + if (!appConfig.androidFullScreen) { + setDeviceOrientation(AppConfig.ScreenOrientations.UNSPECIFIED); + } + } + + @SuppressLint("SourceLockedOrientationActivity") + private void setDeviceOrientation(AppConfig.ScreenOrientations orientation) { + switch (orientation) { + case UNSPECIFIED: + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + break; + case PORTRAIT: + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + break; + case LANDSCAPE: + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE); + break; + } + } + + public TabManager getTabManager() { + return tabManager; + } + + public interface PermissionCallback { + void onPermissionResult(String[] permissions, int[] grantResults); + } + + private class PermissionsCallbackPair { + String[] permissions; + PermissionCallback callback; + + PermissionsCallbackPair(String[] permissions, PermissionCallback callback) { + this.permissions = permissions; + this.callback = callback; + } + } + + public void enableSwipeRefresh() { + if (this.swipeRefreshLayout != null) { + this.swipeRefreshLayout.setEnabled(true); + } + } + + public void restoreSwipRefreshDefault() { + if (this.swipeRefreshLayout != null) { + AppConfig appConfig = AppConfig.getInstance(this); + this.swipeRefreshLayout.setEnabled(appConfig.pullToRefresh); + } + } + + @Override + public void deselectTabs() { + this.tabManager.deselectTabs(); + } + + private void listenForSignalStrength() { + if (this.phoneStateListener != null) return; + + this.phoneStateListener = new PhoneStateListener() { + @Override + public void onSignalStrengthsChanged(SignalStrength signalStrength) { + latestSignalStrength = signalStrength; + sendConnectivityOnce(); + if (connectivityCallback != null) { + sendConnectivity(connectivityCallback); + } + } + }; + + try { + TelephonyManager telephonyManager = (TelephonyManager)this.getSystemService(Context.TELEPHONY_SERVICE); + if (telephonyManager == null) { + GNLog.getInstance().logError(TAG, "Error getting system telephony manager"); + } else { + telephonyManager.listen(this.phoneStateListener, PhoneStateListener.LISTEN_SIGNAL_STRENGTHS); + } + } catch (Exception e) { + GNLog.getInstance().logError(TAG, "Error listening for signal strength", e); + } + + } + + @Override + public void sendConnectivityOnce(String callback) { + if (callback == null) return; + + this.connectivityOnceCallback = callback; + if (this.phoneStateListener != null) { + sendConnectivity(callback); + } else { + listenForSignalStrength(); + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + sendConnectivityOnce(); + } + }, 500); + } + } + + private void sendConnectivityOnce() { + if (this.connectivityOnceCallback == null) return; + sendConnectivity(this.connectivityOnceCallback); + this.connectivityOnceCallback = null; + } + + private void sendConnectivity(String callback) { + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + boolean connected = activeNetwork != null && activeNetwork.isConnected(); + String typeString; + if (activeNetwork != null) { + typeString = activeNetwork.getTypeName(); + } else { + typeString = "DISCONNECTED"; + } + + try { + JSONObject data = new JSONObject(); + data.put("connected", connected); + data.put("type", typeString); + + if (this.latestSignalStrength != null) { + JSONObject signalStrength = new JSONObject(); + + signalStrength.put("cdmaDbm", latestSignalStrength.getCdmaDbm()); + signalStrength.put("cdmaEcio", latestSignalStrength.getCdmaEcio()); + signalStrength.put("evdoDbm", latestSignalStrength.getEvdoDbm()); + signalStrength.put("evdoEcio", latestSignalStrength.getEvdoEcio()); + signalStrength.put("evdoSnr", latestSignalStrength.getEvdoSnr()); + signalStrength.put("gsmBitErrorRate", latestSignalStrength.getGsmBitErrorRate()); + signalStrength.put("gsmSignalStrength", latestSignalStrength.getGsmSignalStrength()); + if (Build.VERSION.SDK_INT >= 23) { + signalStrength.put("level", latestSignalStrength.getLevel()); + } + data.put("cellSignalStrength", signalStrength); + } + + String js = LeanUtils.createJsForCallback(callback, data); + runJavascript(js); + } catch (JSONException e) { + GNLog.getInstance().logError(TAG, "JSON error sending connectivity", e); + } + } + + @Override + public void subscribeConnectivity(final String callback) { + this.connectivityCallback = callback; + listenForSignalStrength(); + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + sendConnectivity(callback); + } + }, 500); + } + + @Override + public void unsubscribeConnectivity() { + this.connectivityCallback = null; + } + + public interface GeolocationPermissionCallback { + void onResult(boolean granted); + } + + // set brightness to a negative number to restore default + @Override + public void setBrightness(float brightness) { + WindowManager.LayoutParams layout = getWindow().getAttributes(); + layout.screenBrightness = brightness; + getWindow().setAttributes(layout); + } + + @Override + public void setSidebarNavigationEnabled(boolean enabled) { + sidebarNavigationEnabled = enabled; + setDrawerEnabled(enabled); + } + + public GoNativeDrawerLayout getDrawerLayout() { + return this.mDrawerLayout; + } + + public ActionBarDrawerToggle getDrawerToggle() { + return this.mDrawerToggle; + } + + /** + * @param appTheme set to null if will use sharedPreferences + */ + + @Override + public void setupAppTheme(String appTheme) { + ConfigPreferences preferences = new ConfigPreferences(this); + preferences.setAppTheme(appTheme); + + // Updating app theme on runtime triggers a configuration change and recreates the app + // To prevent consecutive calls, ignore theme setup on onCreate() by enabling this flag + flagThemeConfigurationChange = true; + + if ("light".equals(appTheme)) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + } else if ("dark".equals(appTheme)) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } + } + + @SuppressLint("RequiresFeature") + private void setupWebviewTheme(String appTheme) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + Log.d(TAG, "Dark mode feature is not supported"); + return; + } + + if (mWebview.getSettings() == null) { + return; + } + + if ("dark".equals(appTheme)) { + WebSettingsCompat.setForceDark(this.mWebview.getSettings(), WebSettingsCompat.FORCE_DARK_ON); + } else if ("light".equals(appTheme)) { + WebSettingsCompat.setForceDark(this.mWebview.getSettings(), WebSettingsCompat.FORCE_DARK_OFF); + } else { + switch (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) { + case Configuration.UI_MODE_NIGHT_YES: + WebSettingsCompat.setForceDark(this.mWebview.getSettings(), WebSettingsCompat.FORCE_DARK_ON); + break; + case Configuration.UI_MODE_NIGHT_NO: + case Configuration.UI_MODE_NIGHT_UNDEFINED: + WebSettingsCompat.setForceDark(this.mWebview.getSettings(), WebSettingsCompat.FORCE_DARK_OFF); + break; + } + + // Force dark on if supported, and only use theme from web + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { + WebSettingsCompat.setForceDarkStrategy( + this.mWebview.getSettings(), + WebSettingsCompat.DARK_STRATEGY_WEB_THEME_DARKENING_ONLY + ); + } + } + } + + private void validateGoogleService() { + try { + if (BuildConfig.GOOGLE_SERVICE_INVALID) { + Toast.makeText(this, R.string.google_service_required, Toast.LENGTH_LONG).show(); + GNLog.getInstance().logError(TAG, "validateGoogleService: " + R.string.google_service_required, null, GNLog.TYPE_TOAST_ERROR); + } + } catch (NullPointerException ex) { + GNLog.getInstance().logError(TAG, "validateGoogleService: " + ex.getMessage(), null, GNLog.TYPE_TOAST_ERROR); + } + } + + @SuppressLint("DiscouragedApi") + private boolean isAndroidGestureEnabled() { + if (Build.VERSION.SDK_INT < 29) return false; + try { + int resourceId = getResources().getIdentifier("config_navBarInteractionMode", "integer", "android"); + if (resourceId > 0) { + // 0 : Navigation is displaying with 3 buttons + // 1 : Navigation is displaying with 2 button(Android P navigation mode) + // 2 : Full screen gesture(Gesture on android Q) + return getResources().getInteger(resourceId) == 2; + } + return false; + } catch (Resources.NotFoundException ex) { + GNLog.getInstance().logError(TAG, "isAndroidGestureEnabled: ", ex); + return false; + } + } + + @Override + public void updateStatusBarOverlay(boolean isOverlayEnabled) { + WindowCompat.setDecorFitsSystemWindows(getWindow(), !isOverlayEnabled); + } + + @Override + public void updateStatusBarStyle(String statusBarStyle) { + if (!TextUtils.isEmpty(statusBarStyle) && Build.VERSION.SDK_INT >= 23) { + WindowInsetsControllerCompat controllerCompat = WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); + switch (statusBarStyle) { + case "light": { + controllerCompat.setAppearanceLightStatusBars(true); + break; + } + case "dark": { + controllerCompat.setAppearanceLightStatusBars(false); + break; + } + case "auto": + int nightModeFlags = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) { + controllerCompat.setAppearanceLightStatusBars(false); + } else if (nightModeFlags == Configuration.UI_MODE_NIGHT_NO) { + controllerCompat.setAppearanceLightStatusBars(true); + } else { + GNLog.getInstance().logError(TAG, "updateStatusBarStyle: Current mode is undefined"); + } + break; + } + } + } + + @Override + public void setStatusBarColor(int color) { + getWindow().setStatusBarColor(color); + } + + @Override + public void runGonativeDeviceInfo(String callback, boolean includeCarrierNames) { + if (includeCarrierNames) { + deviceInfoCallback = callback; + requestPermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE); + } else { + Map installationInfo = Installation.getInfo(this); + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); + if (!sharedPreferences.getBoolean("hasLaunched", false)) { + sharedPreferences.edit().putBoolean("hasLaunched", true).commit(); + installationInfo.put("isFirstLaunch", true); + } else { + installationInfo.put("isFirstLaunch", false); + } + + // insert additional device info from other plugins + GoNativeApplication application = (GoNativeApplication)getApplication(); + installationInfo.putAll(application.mBridge.getExtraDeviceInfo(this)); + + JSONObject jsonObject = new JSONObject(installationInfo); + String js = LeanUtils.createJsForCallback(callback, jsonObject); + this.runJavascript(js); + } + } + + @Override + public Map getDeviceInfo() { + return Installation.getInfo(this); + } + + @Override + public void windowFlag(boolean add, int flag) { + if (add) { + getWindow().addFlags(flag); + } else { + getWindow().clearFlags(flag); + } + } + + @Override + public void setCustomTitle(String title) { + if (!title.isEmpty()) { + setTitle(title); + } else { + setTitle(R.string.app_name); + } + } + + @Override + public void downloadFile(String url, String filename, boolean shouldSaveToGallery, boolean open) { + fileDownloader.downloadFile(url, filename, shouldSaveToGallery, open); + } + + @Override + public void selectTab(int tabNumber) { + if (tabManager == null) return; + tabManager.selectTabNumber(tabNumber, false); + } + + @Override + public void setTabsWithJson(JSONObject tabsJson, int tabMenuId) { + if (tabManager == null) return; + tabManager.setTabsWithJson(tabsJson, tabMenuId); + } + + @Override + public void focusAudio(boolean enabled) { + if (enabled) { + AudioUtils.requestAudioFocus(this); + } else { + AudioUtils.abandonFocusRequest(this); + } + } + + @Override + public void clipboardSet(String content) { + if (content.isEmpty()) return; + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("copy", content); + clipboard.setPrimaryClip(clip); + } + + @Override + public void clipboardGet(String callback) { + if (!TextUtils.isEmpty(callback)) { + Map params = new HashMap<>(); + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + CharSequence pasteData; + if (clipboard.hasPrimaryClip()) { + ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0); + pasteData = item.getText(); + if (pasteData != null) + params.put("data", pasteData.toString()); + else + params.put("error", "Clipboard item is not a string."); + } else { + params.put("error", "No Clipboard item available."); + } + JSONObject jsonObject = new JSONObject(params); + runJavascript(LeanUtils.createJsForCallback(callback, jsonObject)); + } + } + + @Override + public void sendRegistration(JSONObject data) { + if(registrationManager == null) return; + + if(data != null){ + JSONObject customData = data.optJSONObject("customData"); + if(customData == null){ + try { // try converting json string from url to json object + customData = new JSONObject(data.optString("customData")); + } catch (JSONException e){ + GNLog.getInstance().logError(TAG, "GoNative Registration JSONException:- " + e.getMessage(), e); + } + } + if(customData != null){ + registrationManager.setCustomData(customData); + } + } + registrationManager.sendToAllEndpoints(); + } + + @Override + public void runCustomNativeBridge(Map params) { + // execute code defined by the CustomCodeHandler + // call JsCustomCodeExecutor#setHandler to override this default handler + JSONObject data = JsCustomCodeExecutor.execute(params); + String callback = params.get("callback"); + if(callback != null && !callback.isEmpty()) { + final String js = LeanUtils.createJsForCallback(callback, data); + // run on main thread + Handler mainHandler = new Handler(getMainLooper()); + mainHandler.post(() -> runJavascript(js)); + } + } + + @Override + public void promptLocationService() { + getRuntimeGeolocationPermission(granted -> Log.d(TAG, "promptLocationService: " + granted)); + } + + @Override + public boolean isLocationServiceEnabled() { + + if (!isLocationPermissionGranted()) { + return false; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + LocationManager lm = getSystemService(LocationManager.class); + return lm.isLocationEnabled(); + } else { + // This is Deprecated in API 28 + int mode = Settings.Secure.getInt(getContentResolver(), Settings.Secure.LOCATION_MODE, Settings.Secure.LOCATION_MODE_OFF); + return (mode != Settings.Secure.LOCATION_MODE_OFF); + } + } + + private boolean isLocationPermissionGranted() { + int checkFine = ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION); + int checkCoarse = ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION); + return checkFine == PackageManager.PERMISSION_GRANTED && checkCoarse == PackageManager.PERMISSION_GRANTED; + } + + @Override + public void setRestoreBrightnessOnNavigation(boolean restore) { + this.restoreBrightnessOnNavigation = restore; + } + + public boolean isRestoreBrightnessOnNavigation() { + return this.restoreBrightnessOnNavigation; + } + + + + public Object getJavascriptBridge() { + GoNativeApplication application = (GoNativeApplication)getApplication(); + return application.mBridge.getJavaScriptBridge(); + } + + @Override + public void closeCurrentWindow() { + if (!getGNWindowManager().isRoot(activityId)) { + this.finish(); + } + } + + @Override + public void openNewWindow(String url, String mode) { + if (TextUtils.isEmpty(url)) return; + + Uri uri = Uri.parse(url); + + // Same window + if ("internal".equals(mode)) { + loadUrl(url); + return; + } + + // External default browser + if ("external".equals(mode)) { + openExternalBrowser(uri); + return; + } + + // Chrome in-app custom tab + if ("appbrowser".equals(mode)) { + openAppBrowser(uri); + return; + } + + // Default + AppConfig appConfig = AppConfig.getInstance(this); + + // Check maxWindows conditions + if (appConfig.maxWindowsEnabled && appConfig.numWindows > 0 && getGNWindowManager().getWindowCount() >= appConfig.numWindows && onMaxWindowsReached(url)) + return; + + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra("isRoot", false); + intent.putExtra("url", url); + intent.putExtra(MainActivity.EXTRA_IGNORE_INTERCEPT_MAXWINDOWS, true); + startActivityForResult(intent, MainActivity.REQUEST_WEB_ACTIVITY); + } + + public void openExternalBrowser(Uri uri) { + if (uri == null) return; + try { + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + if (!TextUtils.isEmpty(getDefaultBrowserPackageName())) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setPackage(getDefaultBrowserPackageName()); + } + startActivity(intent); + } catch (Exception ex) { + if (ex instanceof ActivityNotFoundException) { + Toast.makeText(this, R.string.app_not_installed, Toast.LENGTH_LONG).show(); + GNLog.getInstance().logError(TAG, getString(R.string.app_not_installed), ex, GNLog.TYPE_TOAST_ERROR); + } else { + GNLog.getInstance().logError(TAG, "openExternalBrowser: launchError - uri: " + uri, ex); + } + } + } + + public void openAppBrowser(Uri uri) { + if (uri == null) return; + try { + CustomTabColorSchemeParams params = new CustomTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary)) + .setSecondaryToolbarColor(ContextCompat.getColor(this, R.color.titleTextColor)) + .build(); + + CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() + .setDefaultColorSchemeParams(params) + .build(); + customTabsIntent.intent.setData(uri); + appBrowserActivityLauncher.launch(customTabsIntent.intent); + } catch (Exception ex) { + if (ex instanceof ActivityNotFoundException) { + Toast.makeText(this, R.string.app_not_installed, Toast.LENGTH_LONG).show(); + GNLog.getInstance().logError(TAG, getString(R.string.app_not_installed), ex, GNLog.TYPE_TOAST_ERROR); + } else { + GNLog.getInstance().logError(TAG, "openAppBrowser: launchError - uri: " + uri, ex); + } + } + } + + @Override + public boolean onMaxWindowsReached(String url) { + AppConfig appConfig = AppConfig.getInstance(this); + GoNativeWindowManager windowManager = getGNWindowManager(); + + if (appConfig.autoClose && LeanUtils.urlsMatchIgnoreTrailing(url, appConfig.getInitialUrl())) { + + // Set this activity as new root + isRoot = true; + + windowManager.setAsNewRoot(activityId); + + // Reset URL levels + windowManager.setUrlLevels(activityId, -1, -1); + + // Reload activity as root + initialRootSetup(); + if (appConfig.showActionBar || appConfig.showNavigationMenu) { + setupProfilePicker(); + } + + showNavigationMenu(appConfig.showNavigationMenu); + + if (actionManager != null) { + actionManager.setupActionBar(isRoot); + actionManager.setupTitleDisplayForUrl(url); + } + + if (mDrawerToggle != null && appConfig.showNavigationMenu) { + mDrawerToggle.syncState(); + } + + windowManager.setIgnoreInterceptMaxWindows(activityId, true); + + // Send broadcast to close other activity + Intent intent = new Intent(MainActivity.BROADCAST_RECEIVER_ACTION_WEBVIEW_LIMIT_REACHED); + intent.putExtra(MainActivity.EXTRA_NEW_ROOT_URL, url); + LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + + // Add listener when all excess windows are closed + windowManager.setOnExcessWindowClosedListener(() -> { + // Load new URL + mWebview.loadUrl(url); + // Remove listener + windowManager.setOnExcessWindowClosedListener(null); + }); + + return true; + } else { + + // Get excess window + String excessWindowId = windowManager.getExcessWindow(); + + // Send broadcast to close the excess window + Intent intent = new Intent(MainActivity.BROADCAST_RECEIVER_ACTION_WEBVIEW_LIMIT_REACHED); + intent.putExtra(MainActivity.EXTRA_EXCESS_WINDOW_ID, excessWindowId); + LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + + // Remove from window list + windowManager.removeWindow(excessWindowId); + } + + return false; + } + + @Override + public void getKeyboardInfo(String callback) { + if (keyboardManager == null || TextUtils.isEmpty(callback)) return; + runJavascript(LeanUtils.createJsForCallback(callback, keyboardManager.getKeyboardData())); + } + + @Override + public void addKeyboardListener(String callback) { + if (keyboardManager == null) return; + keyboardManager.setCallback(callback); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + finish(); + } + + public String getLaunchSource() { + return launchSource; + } + + private String getDefaultBrowserPackageName() { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://www.google.com")); + ResolveInfo resolveInfo = getPackageManager().resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY); + + if (resolveInfo != null && resolveInfo.activityInfo != null) { + return resolveInfo.activityInfo.packageName; + } + + return null; + } +} diff --git a/app/src/main/java/co/median/android/MySwipeRefreshLayout.java b/app/src/main/java/co/median/android/MySwipeRefreshLayout.java new file mode 100644 index 0000000..75b4479 --- /dev/null +++ b/app/src/main/java/co/median/android/MySwipeRefreshLayout.java @@ -0,0 +1,39 @@ +package co.median.android; + +import android.content.Context; +import android.util.AttributeSet; + +import co.median.android.widget.GoNativeSwipeRefreshLayout; + +/** + * Created by weiyin on 9/13/15. + * Copyright 2014 GoNative.io LLC + */ +public class MySwipeRefreshLayout extends GoNativeSwipeRefreshLayout { + private CanChildScrollUpCallback canChildScrollUpCallback; + + public interface CanChildScrollUpCallback { + boolean canSwipeRefreshChildScrollUp(); + } + + public MySwipeRefreshLayout(Context context) { + super(context); + } + + public MySwipeRefreshLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setCanChildScrollUpCallback(CanChildScrollUpCallback canChildScrollUpCallback) { + this.canChildScrollUpCallback = canChildScrollUpCallback; + } + + @Override + public boolean canChildScrollUp() { + if (canChildScrollUpCallback != null) { + return canChildScrollUpCallback.canSwipeRefreshChildScrollUp(); + } else { + return super.canChildScrollUp(); + } + } +} diff --git a/app/src/main/java/co/median/android/ProfilePicker.java b/app/src/main/java/co/median/android/ProfilePicker.java new file mode 100644 index 0000000..b525153 --- /dev/null +++ b/app/src/main/java/co/median/android/ProfilePicker.java @@ -0,0 +1,129 @@ +package co.median.android; + +import androidx.annotation.NonNull; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.JavascriptInterface; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Spinner; +import android.widget.TextView; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; + +import co.median.median_core.GNLog; + +/** + * Created by weiyin on 5/9/14. + */ +public class ProfilePicker implements AdapterView.OnItemSelectedListener { + private static final String TAG = ProfilePicker.class.getName(); + + private MainActivity mainActivity; + private JSONArray json; + private ArrayList names; + private ArrayList links; + private int selectedIndex; + + private ArrayAdapter adapter; + private Spinner spinner; + private ProfileJsBridge profileJsBridge; + + public ProfilePicker(MainActivity mainActivity, Spinner spinner) { + this.mainActivity = mainActivity; + this.spinner = spinner; + this.names = new ArrayList<>(); + this.links = new ArrayList<>(); + this.spinner.setAdapter(getAdapter()); + this.spinner.setOnItemSelectedListener(this); + this.profileJsBridge = new ProfileJsBridge(); + } + + private void parseJson(String s){ + try { + json = new JSONArray(s); + this.names.clear(); + this.links.clear(); + + for (int i = 0; i < json.length(); i++) { + JSONObject item = json.getJSONObject(i); + + this.names.add(item.optString("name", "")); + this.links.add(item.optString("link", "")); + + if (item.optBoolean("selected", false)){ + selectedIndex = i; + } + } + + mainActivity.runOnUiThread(new Runnable() { + public void run() { + if (selectedIndex < ProfilePicker.this.names.size()) { + ProfilePicker.this.spinner.setSelection(selectedIndex); + } + if (ProfilePicker.this.json != null && + ProfilePicker.this.json.length() > 0) + ProfilePicker.this.spinner.setVisibility(View.VISIBLE); + else + ProfilePicker.this.spinner.setVisibility(View.GONE); + getAdapter().notifyDataSetChanged(); + } + }); + + } catch (JSONException e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + } + + private ArrayAdapter getAdapter(){ + if (adapter == null) { + + adapter = new ArrayAdapter(mainActivity, R.layout.profile_picker_dropdown, names) { + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + TextView view = (TextView) super.getView(position, convertView, parent); + view.setTextColor(mainActivity.getResources().getColor(R.color.sidebarForeground)); + return view; + } + + @Override + public View getDropDownView(int position, View convertView, @NonNull ViewGroup parent) { + TextView view = (TextView) super.getDropDownView(position, convertView, parent); + view.setTextColor(mainActivity.getResources().getColor(R.color.sidebarForeground)); + return view; + } + }; + } + + return adapter; + } + + public void onItemSelected(AdapterView parent, View view, int position, long id) { + // only load if selection has changed + if (position != selectedIndex) { + mainActivity.loadUrl(links.get(position)); + mainActivity.closeDrawers(); + selectedIndex = position; + } + } + + public void onNothingSelected(AdapterView parent) { + // do nothing + } + + public ProfileJsBridge getProfileJsBridge() { + return profileJsBridge; + } + + public class ProfileJsBridge { + @JavascriptInterface + public void parseJson(String s) { + ProfilePicker.this.parseJson(s); + } + } +} diff --git a/app/src/main/java/co/median/android/RegistrationManager.java b/app/src/main/java/co/median/android/RegistrationManager.java new file mode 100644 index 0000000..9a69aac --- /dev/null +++ b/app/src/main/java/co/median/android/RegistrationManager.java @@ -0,0 +1,163 @@ +package co.median.android; + +import android.content.Context; +import android.os.AsyncTask; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import co.median.median_core.GNLog; +import co.median.median_core.LeanUtils; + +/** + * Created by weiyin on 10/4/15. + */ +public class RegistrationManager { + private final static String TAG = RegistrationManager.class.getName(); + + private Context context; + private JSONObject customData; + private String lastUrl; + + private List registrationEndpoints; + + RegistrationManager(Context context) { + this.context = context; + this.registrationEndpoints = new LinkedList<>(); + } + + public void processConfig(JSONArray endpoints) { + registrationEndpoints.clear(); + + if (endpoints == null) return; + + for (int i = 0; i < endpoints.length(); i++) { + JSONObject endpoint = endpoints.optJSONObject(i); + if (endpoint == null) continue; + + String url = LeanUtils.optString(endpoint, "url"); + if (url == null) { + Log.w(TAG, "Invalid registration: endpoint url is null"); + continue; + } + + List urlRegexes = LeanUtils.createRegexArrayFromStrings(endpoint.opt("urlRegex")); + + RegistrationEndpoint registrationEndpoint = new RegistrationEndpoint(url, urlRegexes); + registrationEndpoints.add(registrationEndpoint); + } + } + + public void checkUrl(String url) { + this.lastUrl = url; + for (RegistrationEndpoint endpoint : registrationEndpoints) { + if (LeanUtils.stringMatchesAnyRegex(url, endpoint.urlRegexes)) { + endpoint.sendRegistrationInfo(); + } + } + } + + public void setCustomData(JSONObject customData) { + this.customData = customData; + registrationDataChanged(); + } + + public void sendToAllEndpoints() { + for (RegistrationEndpoint endpoint : registrationEndpoints) { + endpoint.sendRegistrationInfo(); + } + } + + private void registrationDataChanged() { + for (RegistrationEndpoint endpoint : registrationEndpoints) { + if (this.lastUrl != null && + LeanUtils.stringMatchesAnyRegex(this.lastUrl, endpoint.urlRegexes)) { + endpoint.sendRegistrationInfo(); + } + } + } + + public void subscriptionInfoChanged(){ + registrationDataChanged(); + } + + private class RegistrationEndpoint { + private String postUrl; + private List urlRegexes; + + RegistrationEndpoint(String postUrl, List urlRegexes) { + this.postUrl = postUrl; + this.urlRegexes = urlRegexes; + } + + void sendRegistrationInfo() { + new SendRegistrationTask(context, this, RegistrationManager.this).execute(); + } + } + + private static class SendRegistrationTask extends AsyncTask { + private RegistrationEndpoint registrationEndpoint; + private RegistrationManager registrationManager; + private Context context; + + SendRegistrationTask(Context context, RegistrationEndpoint registrationEndpoint, RegistrationManager registrationManager) { + this.registrationEndpoint = registrationEndpoint; + this.registrationManager = registrationManager; + this.context = context; + } + + @Override + protected Void doInBackground(Void... voids) { + Map toSend = new HashMap<>(); + + toSend.putAll(Installation.getInfo(registrationManager.context)); + + // Append provider info to Map toSend + if (((GoNativeApplication) context).getAnalyticsProviderInfo() != null) { + toSend.putAll(((GoNativeApplication) context).getAnalyticsProviderInfo()); + } + + if (registrationManager.customData != null) { + Iterator keys = registrationManager.customData.keys(); + while(keys.hasNext()) { + String key = keys.next(); + toSend.put("customData_" + key, registrationManager.customData.opt(key)); + } + } + + try { + JSONObject json = new JSONObject(toSend); + + URL url = new URL(registrationEndpoint.postUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + OutputStreamWriter writer = new OutputStreamWriter(connection.getOutputStream(), "UTF-8"); + writer.write(json.toString()); + writer.close(); + connection.connect(); + int result = connection.getResponseCode(); + + if (result < 200 || result > 299) { + Log.w(TAG, "Recevied status code " + result + " when posting to " + registrationEndpoint.postUrl); + } + } catch (Exception e) { + GNLog.getInstance().logError(TAG, "Error posting to " + registrationEndpoint.postUrl, e); + } + + return null; + } + } +} diff --git a/app/src/main/java/co/median/android/SegmentedController.java b/app/src/main/java/co/median/android/SegmentedController.java new file mode 100644 index 0000000..b8d62c4 --- /dev/null +++ b/app/src/main/java/co/median/android/SegmentedController.java @@ -0,0 +1,128 @@ +package co.median.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Spinner; + +import org.json.JSONObject; + +import java.util.ArrayList; + +import co.median.median_core.AppConfig; + +/** + * Created by weiyin on 12/20/15. + * Copyright 2014 GoNative.io LLC + */ +public class SegmentedController implements AdapterView.OnItemSelectedListener { + private MainActivity mainActivity; + private ArrayList labels; + private ArrayList urls; + private int selectedIndex; + + private ArrayAdapter adapter; + private Spinner spinner; + + SegmentedController(MainActivity mainActivity, Spinner spinner) { + this.mainActivity = mainActivity; + this.spinner = spinner; + + this.labels = new ArrayList<>(); + this.urls = new ArrayList<>(); + + this.spinner.setAdapter(getAdapter()); + this.spinner.setOnItemSelectedListener(this); + + BroadcastReceiver messageReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) return; + + if (intent.getAction().equals(AppConfig.PROCESSED_SEGMENTED_CONTROL)) { + updateSegmentedControl(); + } + } + }; + LocalBroadcastManager.getInstance(this.mainActivity).registerReceiver( + messageReceiver, new IntentFilter(AppConfig.PROCESSED_SEGMENTED_CONTROL)); + + updateSegmentedControl(); + } + + private void updateSegmentedControl() { + this.labels.clear(); + this.urls.clear(); + this.selectedIndex = -1; + + AppConfig appConfig = AppConfig.getInstance(mainActivity); + if (appConfig.segmentedControl == null) return; + + for (int i = 0; i < appConfig.segmentedControl.size(); i++) { + JSONObject item = appConfig.segmentedControl.get(i); + + String label = item.optString("label", "Invalid"); + String url = item.optString("url", ""); + Boolean selected = item.optBoolean("selected"); + + this.labels.add(i, label); + this.urls.add(i, url); + if (selected) this.selectedIndex = i; + } + + mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + if (selectedIndex > -1) { + spinner.setSelection(selectedIndex); + } + + if (labels.size() > 0) { + spinner.setVisibility(View.VISIBLE); + } else { + spinner.setVisibility(View.GONE); + } + + adapter.notifyDataSetChanged(); + } + }); + + } + + private ArrayAdapter getAdapter() { + if (this.adapter != null) { + return this.adapter; + } + + ArrayAdapter adapter = new ArrayAdapter<>(mainActivity, + android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + this.adapter = adapter; + return adapter; + } + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + // only load if selection has changed + if (position != selectedIndex) { + String url = urls.get(position); + + if (url != null && url.length() > 0) { + mainActivity.loadUrl(url); + } + + mainActivity.closeDrawers(); + selectedIndex = position; + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + // do nothing + } +} diff --git a/app/src/main/java/co/median/android/ShakeDialogFragment.java b/app/src/main/java/co/median/android/ShakeDialogFragment.java new file mode 100644 index 0000000..fba4697 --- /dev/null +++ b/app/src/main/java/co/median/android/ShakeDialogFragment.java @@ -0,0 +1,46 @@ +package co.median.android; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import co.median.android.R; + +public class ShakeDialogFragment extends DialogFragment { + public interface ShakeDialogListener { + public void onClearCache(DialogFragment dialog); + } + + ShakeDialogListener listener; + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(R.string.shake_to_clear_cache) + .setItems(R.array.device_shaken_options, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + if (i == 0) { + listener.onClearCache(ShakeDialogFragment.this); + } + } + }); + return builder.create(); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + try { + listener = (ShakeDialogListener) context; + } catch (ClassCastException e) { + throw new ClassCastException(context.toString() + " must implement ShakeDialogListener"); + } + } +} diff --git a/app/src/main/java/co/median/android/TabManager.java b/app/src/main/java/co/median/android/TabManager.java new file mode 100644 index 0000000..aa07a70 --- /dev/null +++ b/app/src/main/java/co/median/android/TabManager.java @@ -0,0 +1,350 @@ +package co.median.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.ColorStateList; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.navigation.NavigationBarView; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; +import co.median.median_core.LeanUtils; +import io.gonative.android.icons.Icon; + +/** + * Created by Weiyin He on 9/22/14. + * Copyright 2014 GoNative.io LLC + */ +public class TabManager implements NavigationBarView.OnItemSelectedListener { + private static final String TAG = TabManager.class.getName(); + private static final int maxTabs = 5; + + private final MainActivity mainActivity; + private final BottomNavigationView bottomNav; + private final AppConfig appConfig; + + private String currentMenuId; + private String currentUrl; + private JSONArray tabs; + private Map tabMenus; + + private final int iconSize; + private final int iconColor; + + private final Map> tabRegexCache = new HashMap<>(); // regex for each tab to auto-select + private boolean useJavascript; // do not use tabs from config + + private boolean performAction = true; + + TabManager(MainActivity mainActivity, BottomNavigationView bottomNav) { + this.mainActivity = mainActivity; + this.bottomNav = bottomNav; + this.bottomNav.setOnItemSelectedListener(this); + this.appConfig = AppConfig.getInstance(this.mainActivity); + + iconSize = this.mainActivity.getResources().getInteger(R.integer.tabbar_icon_size); + iconColor = mainActivity.getResources().getColor(R.color.tabBarTextColor); + + bottomNav.setBackgroundColor(mainActivity.getResources().getColor(R.color.tabBarBackground)); + + ColorStateList iconColorStates = new ColorStateList( + new int[][]{ + new int[]{-android.R.attr.state_checked}, + new int[]{android.R.attr.state_checked} + }, + new int[]{ + mainActivity.getResources().getColor(R.color.tabBarTextColor), + mainActivity.getResources().getColor(R.color.tabBarIndicator) + }); + + bottomNav.setItemIconTintList(iconColorStates); + bottomNav.setItemTextColor(iconColorStates); + + BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction() != null && intent.getAction().equals(AppConfig.PROCESSED_TAB_NAVIGATION_MESSAGE)) { + currentMenuId = null; + initializeTabMenus(); + checkTabs(currentUrl); + } + } + }; + LocalBroadcastManager.getInstance(this.mainActivity) + .registerReceiver(broadcastReceiver, + new IntentFilter(AppConfig.PROCESSED_TAB_NAVIGATION_MESSAGE)); + + initializeTabMenus(); + } + + private void initializeTabMenus(){ + ArrayList regexes = appConfig.tabMenuRegexes; + ArrayList ids = appConfig.tabMenuIDs; + + if (regexes == null || ids == null) { + return; + } + + tabMenus = new HashMap<>(); + Map tabSelectionConfig = new HashMap<>(); + + for (int i = 0; i < ids.size(); i++) { + tabSelectionConfig.put(ids.get(i), regexes.get(i)); + } + + for (Map.Entry tabMenu : appConfig.tabMenus.entrySet()) { + TabMenu item = new TabMenu(); + item.tabs = tabMenu.getValue(); + item.urlRegex = tabSelectionConfig.get(tabMenu.getKey()); + tabMenus.put(tabMenu.getKey(), item); + } + } + + public void checkTabs(String url) { + this.currentUrl = url; + + if (this.mainActivity == null || url == null) { + return; + } + + if (this.useJavascript) { + autoSelectTab(url); + return; + } + + ArrayList regexes = appConfig.tabMenuRegexes; + ArrayList ids = appConfig.tabMenuIDs; + if (regexes == null || ids == null) { + showTabs(false); + return; + } + + String menuId = null; + + for (int i = 0; i < regexes.size(); i++) { + Pattern regex = regexes.get(i); + if (regex.matcher(url).matches()) { + menuId = ids.get(i); + break; + } + } + + setMenuID(menuId); + + if (menuId != null) autoSelectTab(url); + } + + + + private void setMenuID(String id){ + if (id == null) { + this.currentMenuId = null; + showTabs(false); + } + else if (this.currentMenuId == null || !this.currentMenuId.equals(id)) { + this.currentMenuId = id; + JSONArray tabs = AppConfig.getInstance(this.mainActivity).tabMenus.get(id); + setTabs(tabs); + showTabs(bottomNav.getMenu().size() != 0); + } + } + + private void setTabs(JSONArray tabs) { + this.tabs = tabs; + + int selectedNumber = -1; + bottomNav.getMenu().clear(); + if(tabs == null) return; + + for (int i = 0; i < tabs.length(); i++) { + if(i > (maxTabs-1)){ + GNLog.getInstance().logError(TAG, "Tab menu items list should not have more than 5 items"); + break; + } + + JSONObject item = tabs.optJSONObject(i); + if (item == null) continue; + + String label = item.optString("label"); + String icon = item.optString("icon"); + + // if no label, icon and url is provided, do not include + if(label.isEmpty() && icon.isEmpty() && item.optString("url").isEmpty()){ + continue; + } + + // set default drawable "Question Mark" when no icon provided + if (icon.isEmpty()) { + icon = "faw_question"; + GNLog.getInstance().logError(TAG, "All tabs must have icons."); + } + + bottomNav.getMenu().add(Menu.NONE, i, Menu.NONE, label).setIcon(new Icon(mainActivity, icon, iconSize, iconColor).getDrawable()); + + if (item.optBoolean("selected")) { + selectedNumber = i; + } + } + + if (selectedNumber > -1) { + selectTabNumber(selectedNumber, true); + } + } + + // regex used for auto tab selection + private List getRegexForTab(JSONObject tabConfig) { + if (tabConfig == null) return null; + + Object regex = tabConfig.opt("regex"); + if (regex == null) return null; + + return LeanUtils.createRegexArrayFromStrings(regex); + } + + private List getCachedRegexForTab(int position) { + if (tabs == null || position < 0 || position >= tabs.length()) return null; + + JSONObject tabConfig = tabs.optJSONObject(position); + if (tabConfig == null) return null; + + if (tabRegexCache.containsKey(tabConfig)) { + return tabRegexCache.get(tabConfig); + } else { + List regex = getRegexForTab(tabConfig); + tabRegexCache.put(tabConfig, regex); + return regex; + } + } + + public void autoSelectTab(String url) { + if (tabs == null) return; + + for (int i = 0; i < tabs.length(); i++) { + List patternList = getCachedRegexForTab(i); + if (patternList == null) continue; + + for(Pattern regex : patternList) { + if (regex.matcher(url).matches()) { + bottomNav.setSelectedItemId(i); + return; + } + } + } + } + + @SuppressWarnings("UnusedReturnValue") + public boolean selectTab(String url, String javascript) { + if (url == null) return false; + + if (javascript == null) javascript = ""; + + if (this.tabs != null) { + for (int i = 0; i < this.tabs.length(); i++) { + JSONObject entry = this.tabs.optJSONObject(i); + if (entry != null) { + String entryUrl = entry.optString("url"); + String entryJs = entry.optString("javascript"); + + if (url.equals(entryUrl) && javascript.equals(entryJs)) { + if (this.bottomNav != null) { + this.bottomNav.setSelectedItemId(i); + return true; + } + } + + } + } + } + + return false; + } + + public void setTabsWithJson(JSONObject tabsJson, int tabMenuId) { + if(tabsJson == null) return; + + this.useJavascript = true; + + JSONArray tabs = tabsJson.optJSONArray("items"); + if (tabs != null) setTabs(tabs); + + if(tabMenuId != -1){ + TabMenu tabMenu = tabMenus.get(Integer.toString(tabMenuId)); + if(tabMenu == null || tabs != null) return; + setTabs(tabMenu.tabs); + } + + Object enabled = tabsJson.opt("enabled"); + if (enabled instanceof Boolean) { + showTabs((Boolean) enabled); + } + } + + public void selectTabNumber(int tabNumber, boolean performAction) { + if (tabNumber < 0 || tabNumber >= bottomNav.getMenu().size()) { + return; + } + this.performAction = performAction; + this.bottomNav.setSelectedItemId(tabNumber); + } + + @Override + public boolean onNavigationItemSelected(@NonNull MenuItem item) { + if (this.tabs != null) { + JSONObject entry = this.tabs.optJSONObject(item.getItemId()); + + String url = entry.optString("url"); + String javascript = entry.optString("javascript"); + + if (!performAction) { + performAction = true; + return true; + } + + if (!TextUtils.isEmpty(url)) { + if (!TextUtils.isEmpty(javascript)) mainActivity.loadUrlAndJavascript(url, javascript, true); + else mainActivity.loadUrl(url, true); + } + } + return true; + } + + public void showTabs(boolean show) { + mainActivity.runOnUiThread(() -> { + if (show) this.bottomNav.setVisibility(View.VISIBLE); + else this.bottomNav.setVisibility(View.GONE); + }); + } + + public void deselectTabs() { + Menu menu = bottomNav.getMenu(); + menu.setGroupCheckable(0, true, false); + for(int i = 0; i < menu.size(); i++) { + menu.getItem(i).setChecked(false); + } + menu.setGroupCheckable(0, true, true); + } + + private static class TabMenu { + Pattern urlRegex; + JSONArray tabs; + } +} diff --git a/app/src/main/java/co/median/android/UrlInspector.java b/app/src/main/java/co/median/android/UrlInspector.java new file mode 100644 index 0000000..212e530 --- /dev/null +++ b/app/src/main/java/co/median/android/UrlInspector.java @@ -0,0 +1,63 @@ +package co.median.android; + +import android.content.Context; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; + +/** + * Created by weiyin on 4/28/14. + */ +public class UrlInspector { + private static final String TAG = UrlInspector.class.getName(); + // singleton + private static UrlInspector instance = null; + + private Pattern userIdRegex = null; + + private String userId = null; + + + public static UrlInspector getInstance(){ + if (instance == null) { + instance = new UrlInspector(); + } + return instance; + } + + public void init(Context context) { + String regexString = AppConfig.getInstance(context).userIdRegex; + if (regexString != null && !regexString.isEmpty()) { + try { + userIdRegex = Pattern.compile(regexString); + } catch (PatternSyntaxException e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + } + } + + private UrlInspector() { + // prevent direct instantiation + } + + public void inspectUrl(String url) { + if (userIdRegex != null) { + Matcher matcher = userIdRegex.matcher(url); + if (matcher.groupCount() > 0 && matcher.find()) { + setUserId(matcher.group(1)); + } + } + } + + public String getUserId() { + return userId; + } + + private void setUserId(String userId) { + this.userId = userId; + } +} diff --git a/app/src/main/java/co/median/android/UrlNavigation.java b/app/src/main/java/co/median/android/UrlNavigation.java new file mode 100644 index 0000000..1c65362 --- /dev/null +++ b/app/src/main/java/co/median/android/UrlNavigation.java @@ -0,0 +1,1033 @@ +package co.median.android; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.SharedPreferences; +import android.location.LocationManager; +import android.net.Uri; +import android.net.http.SslError; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.Message; +import android.preference.PreferenceManager; +import android.provider.Settings; +import android.security.KeyChain; +import android.security.KeyChainAliasCallback; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; +import android.util.Pair; +import android.webkit.ClientCertRequest; +import android.webkit.CookieManager; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.RequiresApi; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import co.median.median_core.AppConfig; +import co.median.median_core.GNLog; +import co.median.median_core.GoNativeWebviewInterface; +import co.median.median_core.LeanUtils; +import co.median.median_core.RegexRulesManager; +import co.median.median_core.Utils; + +enum WebviewLoadState { + STATE_UNKNOWN, + STATE_START_LOAD, // we have decided to load the url in this webview in shouldOverrideUrlLoading + STATE_PAGE_STARTED, // onPageStarted has been called + STATE_DONE // onPageFinished has been called +} + +public class UrlNavigation { + public static final String STARTED_LOADING_MESSAGE = "io.gonative.android.webview.started"; + public static final String FINISHED_LOADING_MESSAGE = "io.gonative.android.webview.finished"; + public static final String CLEAR_POOLS_MESSAGE = "io.gonative.android.webview.clearPools"; + + private static final String TAG = UrlNavigation.class.getName(); + + private static final String ASSET_URL = "file:///android_asset/"; + public static final String OFFLINE_PAGE_URL = "file:///android_asset/offline.html"; + public static final String OFFLINE_PAGE_URL_RAW = "file:///offline.html"; + + public static final int DEFAULT_HTML_SIZE = 10 * 1024; // 10 kilobytes + + private MainActivity mainActivity; + private String profilePickerExec; + private String gnProfilePickerExec; + private String currentWebviewUrl; + private String jsBridgeScript; + private HtmlIntercept htmlIntercept; + private Handler startLoadTimeout = new Handler(); + + private WebviewLoadState state = WebviewLoadState.STATE_UNKNOWN; + private boolean mVisitedLoginOrSignup = false; + private boolean finishOnExternalUrl = false; + private double connectionOfflineTime; + + private String interceptedRedirectUrl = ""; + private String CUSTOM_CSS_FILE = "customCSS.css"; + private String CUSTOM_JS_FILE = "customJS.js"; + private String ANDROID_CUSTOM_CSS_FILE = "androidCustomCSS.css"; + private String ANDROID_CUSTOM_JS_FILE = "androidCustomJS.js"; + + UrlNavigation(MainActivity activity) { + this.mainActivity = activity; + this.htmlIntercept = new HtmlIntercept(activity); + + AppConfig appConfig = AppConfig.getInstance(mainActivity); + + // profile picker + if (appConfig.profilePickerJS != null) { + this.profilePickerExec = "median_profile_picker.parseJson(eval(" + + LeanUtils.jsWrapString(appConfig.profilePickerJS) + + "))"; + + this.gnProfilePickerExec = "gonative_profile_picker.parseJson(eval(" + + LeanUtils.jsWrapString(appConfig.profilePickerJS) + + "))"; + } + + if (mainActivity.getIntent().getBooleanExtra(MainActivity.EXTRA_WEBVIEW_WINDOW_OPEN, false)) { + finishOnExternalUrl = true; + } + + connectionOfflineTime = appConfig.androidConnectionOfflineTime; + } + + private boolean isInternalUri(Uri uri) { + String scheme = uri.getScheme(); + if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) { + return false; + } + + AppConfig appConfig = AppConfig.getInstance(mainActivity); + RegexRulesManager regexRulesManager = appConfig.regexRulesManager; + String urlString = uri.toString(); + + // first check regexes + if (!regexRulesManager.isEmpty()) { + return regexRulesManager.getMode(urlString).equals(RegexRulesManager.MODE_INTERNAL); + } + + String host = uri.getHost(); + String initialHost = appConfig.initialHost; + + return host != null && + (host.equals(initialHost) || host.endsWith("." + initialHost)); + } + + public boolean shouldOverrideUrlLoading(GoNativeWebviewInterface view, String url) { + return shouldOverrideUrlLoading(view, url, false, false); + } + + // noAction to skip stuff like opening url in external browser, higher nav levels, etc. + private boolean shouldOverrideUrlLoadingNoIntercept(final GoNativeWebviewInterface view, final String url, + @SuppressWarnings("SameParameterValue") final boolean noAction) { +// Log.d(TAG, "shouldOverrideUrl: " + url); + + // return if url is null (can happen if clicking refresh when there is no page loaded) + if (url == null) + return false; + + // return if loading from local assets + if (url.startsWith(ASSET_URL)) return false; + + if (url.startsWith("blob:")) return false; + + view.setCheckLoginSignup(true); + + Uri uri = Uri.parse(url); + + if (uri.getScheme() != null && uri.getScheme().equals("gonative-bridge")) { + if (noAction) return true; + + try { + String json = uri.getQueryParameter("json"); + + JSONArray parsedJson = new JSONArray(json); + for (int i = 0; i < parsedJson.length(); i++) { + JSONObject entry = parsedJson.optJSONObject(i); + if (entry == null) continue; + + String command = entry.optString("command"); + if (command.isEmpty()) continue; + + if (command.equals("pop")) { + if (mainActivity.isNotRoot()) mainActivity.finish(); + } else if (command.equals("clearPools")) { + LocalBroadcastManager.getInstance(mainActivity).sendBroadcast( + new Intent(UrlNavigation.CLEAR_POOLS_MESSAGE)); + } + } + } catch (Exception e) { + // do nothing + } + + return true; + } + + final AppConfig appConfig = AppConfig.getInstance(mainActivity); + // Check native bridge urls + if (("median".equals(uri.getScheme()) || "gonative".equals(uri.getScheme())) && currentWebviewUrl != null && + !LeanUtils.checkNativeBridgeUrls(currentWebviewUrl, mainActivity)) { + GNLog.getInstance().logError(TAG, "URL not authorized for native bridge: " + currentWebviewUrl); + return true; + } + + if ("median".equals(uri.getScheme()) || "gonative".equals(uri.getScheme())) { + ((GoNativeApplication) mainActivity.getApplication()).mBridge.handleJSBridgeFunctions(mainActivity, uri); + return true; + } + + // check redirects + if (appConfig.getRedirects() != null) { + String to = appConfig.getRedirects().get(url); + if (to == null) to = appConfig.getRedirects().get("*"); + if (to != null && !to.equals(url)) { + if (noAction) return true; + + final String destination = to; + mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + mainActivity.loadUrl(destination); + } + }); + return true; + } + } + + if (!isInternalUri(uri)) { + if (noAction) return true; + + String mode = appConfig.regexRulesManager.getMode(uri.toString()); + if (mode.equals(RegexRulesManager.MODE_APP_BROWSER)) { + mainActivity.openAppBrowser(uri); + } else { + Log.d(TAG, "processing dynamic link: " + uri); + Intent intent = null; + // launch browser + try { + if ("intent".equals(uri.getScheme())) { + intent = Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME); + mainActivity.startActivity(intent); + } else if ("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) { + // forces this URL to be launched in this device's default browser, regardless of the app's deeplink + mainActivity.openExternalBrowser(uri); + } else { + intent = new Intent(Intent.ACTION_VIEW, uri); + mainActivity.startActivity(intent); + } + } catch (ActivityNotFoundException ex) { + // Try loading fallback url if available + if (intent != null) { + String fallbackUrl = intent.getStringExtra("browser_fallback_url"); + if (!TextUtils.isEmpty(fallbackUrl)) { + mainActivity.loadUrl(fallbackUrl); + } else { + Toast.makeText(mainActivity, R.string.app_not_installed, Toast.LENGTH_LONG).show(); + GNLog.getInstance().logError(TAG, mainActivity.getString(R.string.app_not_installed), ex, GNLog.TYPE_TOAST_ERROR); + } + } + } catch (URISyntaxException e) { + GNLog.getInstance().logError(TAG, e.getMessage(), e); + } + } + + // If this URL launched the app initially via deeplink action, + // load initialURL so the app does not show a blank page. + if (AppLinksActivity.LAUNCH_SOURCE_APP_LINKS.equals(mainActivity.getLaunchSource()) && getCurrentWebviewUrl() == null) { + mainActivity.loadUrl(appConfig.getInitialUrl()); + } + return true; + } + + // Starting here, we are going to load the request, but possibly in a + // different activity depending on the structured nav level + + if (!mainActivity.isRestoreBrightnessOnNavigation()) { + mainActivity.setBrightness(-1); + mainActivity.setRestoreBrightnessOnNavigation(false); + } + + if (appConfig.maxWindowsEnabled) { + + GoNativeWindowManager windowManager = mainActivity.getGNWindowManager(); + + // To prevent consecutive calls and handle MaxWindows correctly + // Checks for a flag indicating if the Activity was created from CreateNewWindow OR NavLevels + // and avoid triggering MaxWindows during this initial intercept + boolean ignoreInterceptMaxWindows = windowManager.isIgnoreInterceptMaxWindows(mainActivity.getActivityId()); + + if (ignoreInterceptMaxWindows) { + windowManager.setIgnoreInterceptMaxWindows(mainActivity.getActivityId(), false); + } else if (appConfig.numWindows > 0 && windowManager.getWindowCount() >= appConfig.numWindows) { + if (mainActivity.onMaxWindowsReached(url)) { + return true; + } + } + } + + int currentLevel = mainActivity.getUrlLevel(); + int newLevel = mainActivity.urlLevelForUrl(url); + if (currentLevel >= 0 && newLevel >= 0) { + if (newLevel > currentLevel) { + if (noAction) return true; + + // new activity + Intent intent = new Intent(mainActivity.getBaseContext(), MainActivity.class); + intent.putExtra("isRoot", false); + intent.putExtra("url", url); + intent.putExtra("parentUrlLevel", currentLevel); + intent.putExtra("postLoadJavascript", mainActivity.postLoadJavascript); + + if (appConfig.maxWindowsEnabled) { + intent.putExtra(MainActivity.EXTRA_IGNORE_INTERCEPT_MAXWINDOWS, true); + } + + mainActivity.startActivityForResult(intent, MainActivity.REQUEST_WEB_ACTIVITY); + + mainActivity.postLoadJavascript = null; + mainActivity.postLoadJavascriptForRefresh = null; + + return true; + } else if (newLevel < currentLevel && newLevel <= mainActivity.getParentUrlLevel()) { + if (noAction) return true; + + // pop activity + Intent returnIntent = new Intent(); + returnIntent.putExtra("url", url); + returnIntent.putExtra("urlLevel", newLevel); + returnIntent.putExtra("postLoadJavascript", mainActivity.postLoadJavascript); + mainActivity.setResult(Activity.RESULT_OK, returnIntent); + mainActivity.finish(); + return true; + } + } + + // Starting here, the request will be loaded in this activity. + if (newLevel >= 0) { + mainActivity.setUrlLevel(newLevel); + } + + final String newTitle = mainActivity.titleForUrl(url); + if (newTitle != null) { + if (!noAction) { + mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + mainActivity.setTitle(newTitle); + } + }); + } + } + + // nav title image + if (!noAction) { + mainActivity.runOnUiThread(() -> + mainActivity.getActionManager().setupTitleDisplayForUrl(url) + ); + } + + // check to see if the webview exists in pool. + WebViewPool webViewPool = ((GoNativeApplication) mainActivity.getApplication()).getWebViewPool(); + Pair pair = webViewPool.webviewForUrl(url); + final GoNativeWebviewInterface poolWebview = pair.first; + WebViewPoolDisownPolicy poolDisownPolicy = pair.second; + + if (noAction && poolWebview != null) return true; + + if (poolWebview != null && poolDisownPolicy == WebViewPoolDisownPolicy.Always) { + this.mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + mainActivity.switchToWebview(poolWebview, true, false); + mainActivity.checkNavigationForPage(url); + } + }); + webViewPool.disownWebview(poolWebview); + LocalBroadcastManager.getInstance(mainActivity).sendBroadcast(new Intent(UrlNavigation.FINISHED_LOADING_MESSAGE)); + return true; + } + + if (poolWebview != null && poolDisownPolicy == WebViewPoolDisownPolicy.Never) { + this.mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + mainActivity.switchToWebview(poolWebview, true, false); + mainActivity.checkNavigationForPage(url); + } + }); + return true; + } + + if (poolWebview != null && poolDisownPolicy == WebViewPoolDisownPolicy.Reload && + !LeanUtils.urlsMatchOnPath(url, this.currentWebviewUrl)) { + this.mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + mainActivity.switchToWebview(poolWebview, true, false); + mainActivity.checkNavigationForPage(url); + } + }); + return true; + } + + if (this.mainActivity.isPoolWebview) { + // if we are here, either the policy is reload and we are reloading the page, or policy is never but we are going to a different page. So take ownership of the webview. + webViewPool.disownWebview(view); + this.mainActivity.isPoolWebview = false; + } + + return false; + } + + public boolean shouldOverrideUrlLoading(final GoNativeWebviewInterface view, String url, + @SuppressWarnings("unused") boolean isReload, boolean isRedirect) { + if (url == null) return false; + + boolean shouldOverride = shouldOverrideUrlLoadingNoIntercept(view, url, false); + if (shouldOverride) { + if (finishOnExternalUrl) { + mainActivity.finish(); + } + + // Check if intercepted URL request was a result of a server-side redirect. + // Redirect URLs triggers redundant onPageFinished() + if (isRedirect) { + interceptedRedirectUrl = url; + } + return true; + } else { + finishOnExternalUrl = false; + } + + // intercept html + this.htmlIntercept.setInterceptUrl(url); + mainActivity.hideWebview(); + state = WebviewLoadState.STATE_START_LOAD; + // 10 second (default) delay to get to onPageStarted or doUpdateVisitedHistory + if (!Double.isNaN(connectionOfflineTime) && !Double.isInfinite(connectionOfflineTime) && + connectionOfflineTime > 0) { + startLoadTimeout.postDelayed(new Runnable() { + @Override + public void run() { + AppConfig appConfig = AppConfig.getInstance(mainActivity); + String url = view.getUrl(); + if (appConfig.showOfflinePage && !OFFLINE_PAGE_URL.equals(url)) { + view.loadUrlDirect(OFFLINE_PAGE_URL); + } + } + }, (long) (connectionOfflineTime * 1000)); + } + + return false; + } + + public void onPageStarted(String url) { + // catch blank pages from htmlIntercept and cancel loading + if (url.equals(htmlIntercept.getRedirectedUrl())) { + mainActivity.goBack(); + htmlIntercept.setRedirectedUrl(null); + return; + } + + state = WebviewLoadState.STATE_PAGE_STARTED; + startLoadTimeout.removeCallbacksAndMessages(null); + htmlIntercept.setInterceptUrl(url); + + UrlInspector.getInstance().inspectUrl(url); + Uri uri = Uri.parse(url); + + // reload menu if internal url + if (AppConfig.getInstance(mainActivity).loginDetectionUrl != null && isInternalUri(uri)) { + mainActivity.updateMenu(); + } + + // check ready status + mainActivity.startCheckingReadyStatus(); + + mainActivity.checkPreNavigationForPage(url); + + // send broadcast message + LocalBroadcastManager.getInstance(mainActivity).sendBroadcast(new Intent(UrlNavigation.STARTED_LOADING_MESSAGE)); + + + // enable swipe refresh controller if offline page + if (OFFLINE_PAGE_URL.equals(url)) { + mainActivity.enableSwipeRefresh(); + } else { + mainActivity.restoreSwipRefreshDefault(); + } + } + + @SuppressWarnings("unused") + public void showWebViewImmediately() { + mainActivity.runOnUiThread(() -> mainActivity.showWebviewImmediately()); + } + + @SuppressLint("ApplySharedPref") + public void onPageFinished(GoNativeWebviewInterface view, String url) { + // Catch intercepted Redirect URL to + // prevent loading unnecessary components + if (interceptedRedirectUrl.equals(url)) { + interceptedRedirectUrl = ""; + return; + } + + Log.d(TAG, "onpagefinished " + url); + state = WebviewLoadState.STATE_DONE; + setCurrentWebviewUrl(url); + + AppConfig appConfig = AppConfig.getInstance(mainActivity); + if (url != null && appConfig.ignorePageFinishedRegexes != null) { + for (Pattern pattern : appConfig.ignorePageFinishedRegexes) { + if (pattern.matcher(url).matches()) return; + } + } + + mainActivity.runOnUiThread(() -> mainActivity.showWebview()); + + UrlInspector.getInstance().inspectUrl(url); + + Uri uri = Uri.parse(url); + if (isInternalUri(uri)) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() { + @Override + public void run() { + CookieManager.getInstance().flush(); + } + }); + } + + // inject median library + if (appConfig.injectMedianJS) { + injectJSBridgeLibrary(currentWebviewUrl); + } + + // inject custom CSS and JS + injectCSSviaJavascript(); + injectJSviaJavascript(); + + if (appConfig.loginDetectionUrl != null) { + if (mVisitedLoginOrSignup) { + mainActivity.updateMenu(); + } + + mVisitedLoginOrSignup = LeanUtils.urlsMatchOnPath(url, appConfig.loginUrl) || + LeanUtils.urlsMatchOnPath(url, appConfig.signupUrl); + } + + // post-load javascript + if (appConfig.postLoadJavascript != null) { + view.runJavascript(appConfig.postLoadJavascript); + } + + // profile picker + if (this.profilePickerExec != null) { + view.runJavascript(this.profilePickerExec); + } + + if (this.gnProfilePickerExec != null) { + view.runJavascript(this.gnProfilePickerExec); + } + + // tabs + mainActivity.checkNavigationForPage(url); + + // post-load javascript + if (mainActivity.postLoadJavascript != null) { + String js = mainActivity.postLoadJavascript; + mainActivity.postLoadJavascript = null; + mainActivity.runJavascript(js); + } + + // send broadcast message + LocalBroadcastManager.getInstance(mainActivity).sendBroadcast(new Intent(UrlNavigation.FINISHED_LOADING_MESSAGE)); + + boolean doNativeBridge = true; + if (currentWebviewUrl != null) { + doNativeBridge = LeanUtils.checkNativeBridgeUrls(currentWebviewUrl, mainActivity); + } + + // send installation info + if (doNativeBridge) { + runGonativeDeviceInfo("median_device_info"); + runGonativeDeviceInfo("gonative_device_info"); + } + + ((GoNativeApplication) mainActivity.getApplication()).mBridge.onPageFinish(mainActivity, doNativeBridge); + } + + private void injectJSBridgeLibrary(String currentWebviewUrl) { + if(!LeanUtils.checkNativeBridgeUrls(currentWebviewUrl, mainActivity)) return; + + try { + if(jsBridgeScript == null) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + InputStream is = new BufferedInputStream(mainActivity.getAssets().open("GoNativeJSBridgeLibrary.js")); + IOUtils.copy(is, baos); + jsBridgeScript = baos.toString(); + } + mainActivity.runJavascript(jsBridgeScript); + ((GoNativeApplication) mainActivity.getApplication()).mBridge.injectJSLibraries(mainActivity); + // call the user created function that needs library access on page finished. + mainActivity.runJavascript(LeanUtils.createJsForCallback("median_library_ready", null)); + mainActivity.runJavascript(LeanUtils.createJsForCallback("gonative_library_ready", null)); + Log.d(TAG, "GoNative JSBridgeLibrary Injection Success"); + } catch (Exception e) { + Log.d(TAG, "GoNative JSBridgeLibrary Injection Error:- " + e.getMessage()); + } + } + + private void injectCSSviaJavascript() { + AppConfig appConfig = AppConfig.getInstance(mainActivity); + if (!appConfig.hasCustomCSS && !appConfig.hasAndroidCustomCSS) return; + + List filePaths = new ArrayList<>(); + // read customCSS.css file + if(appConfig.hasCustomCSS) { + filePaths.add(CUSTOM_CSS_FILE); + } + // read android customCSS.css file + if(appConfig.hasAndroidCustomCSS){ + filePaths.add(ANDROID_CUSTOM_CSS_FILE); + } + if(filePaths.size() == 0) return; + + // inject custom CSS + try { + String cssString = readAssetsToString(filePaths); + if(cssString.length() == 0) return; + String encoded = Base64.encodeToString(cssString.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + String js = "(function() {" + + "var parent = document.getElementsByTagName('head').item(0);" + + "var style = document.createElement('style');" + + "style.type = 'text/css';" + + // Tell the browser to BASE64-decode the string into your script !!! + "style.innerHTML = window.atob('" + encoded + "');" + + "parent.appendChild(style)" + + "})()"; + mainActivity.runJavascript(js); + Log.d(TAG, "Custom CSS Injection Success"); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, "Error injecting customCSS via javascript", e); + } + } + + private void injectJSviaJavascript() { + AppConfig appConfig = AppConfig.getInstance(mainActivity); + if (!appConfig.hasCustomJS && !appConfig.hasAndroidCustomJS) return; + + List filePaths = new ArrayList<>(); + // read customJS file + if(appConfig.hasCustomJS){ + filePaths.add(CUSTOM_JS_FILE); + } + // read android customJS file + if(appConfig.hasAndroidCustomJS){ + filePaths.add(ANDROID_CUSTOM_JS_FILE); + } + if(filePaths.size() == 0) return; + + try { + String jsString = readAssetsToString(filePaths); + if(jsString.length() == 0) return; + String encoded = Base64.encodeToString(jsString.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + String js = "javascript:(function() {" + + "var parent = document.getElementsByTagName('head').item(0);" + + "var script = document.createElement('script');" + + "script.type = 'text/javascript';" + + "script.innerHTML = window.atob('" + encoded + "');" + + "parent.appendChild(script)" + + "})()"; + mainActivity.runJavascript(js); + Log.d(TAG, "Custom JS Injection Success"); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, "Error injecting customJS via javascript", e); + } + } + + private String readAssetsToString(List paths) { + StringBuilder builder = new StringBuilder(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + for (String path : paths) { + try { + co.median.median_core.IOUtils.copy(new BufferedInputStream(mainActivity.getAssets().open(path)), baos); + builder.append(baos); + baos.reset(); + } catch (IOException ioe) { + Log.e(TAG, "Error reading " + path, ioe); + } + } + co.median.median_core.IOUtils.close(baos); + return builder.toString(); + } + + public void onFormResubmission(GoNativeWebviewInterface view, Message dontResend, Message resend) { + resend.sendToTarget(); + } + + private void runGonativeDeviceInfo(String callback) { + Map installationInfo = Installation.getInfo(mainActivity); + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mainActivity); + if (!sharedPreferences.getBoolean("hasLaunched", false)) { + sharedPreferences.edit().putBoolean("hasLaunched", true).commit(); + installationInfo.put("isFirstLaunch", true); + } else { + installationInfo.put("isFirstLaunch", false); + } + + JSONObject jsonObject = new JSONObject(installationInfo); + String js = LeanUtils.createJsForCallback(callback, jsonObject); + mainActivity.runJavascript(js); + } + + public void doUpdateVisitedHistory(@SuppressWarnings("unused") GoNativeWebviewInterface view, String url, boolean isReload) { + if (state == WebviewLoadState.STATE_START_LOAD) { + state = WebviewLoadState.STATE_PAGE_STARTED; + startLoadTimeout.removeCallbacksAndMessages(null); + } + + if (!isReload && !url.equals(OFFLINE_PAGE_URL)) { + mainActivity.addToHistory(url); + } + } + + public void onReceivedError(final GoNativeWebviewInterface view, + @SuppressWarnings("unused") int errorCode, + String errorDescription, String failingUrl) { + if (errorDescription != null && errorDescription.contains("net::ERR_CACHE_MISS")) { + mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + view.reload(); + } + }); + return; + } + + boolean showingOfflinePage = false; + + // show offline page if not connected to internet + AppConfig appConfig = AppConfig.getInstance(this.mainActivity); + if (appConfig.showOfflinePage && + (state == WebviewLoadState.STATE_PAGE_STARTED || state == WebviewLoadState.STATE_START_LOAD)) { + + if (mainActivity.isDisconnected() || + (errorCode == WebViewClient.ERROR_HOST_LOOKUP && + failingUrl != null && + view.getUrl() != null && + failingUrl.equals(view.getUrl()))) { + + showingOfflinePage = true; + + mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + view.stopLoading(); + view.loadUrlDirect(OFFLINE_PAGE_URL); + + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + view.loadUrlDirect(OFFLINE_PAGE_URL); + } + }, 100); + } + }); + } + } + + if (!showingOfflinePage) { + mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + mainActivity.showWebview(); + } + }); + } + } + + public void onReceivedSslError(SslError error, String webviewUrl) { + int errorMessage; + switch (error.getPrimaryError()) { + case SslError.SSL_EXPIRED: + errorMessage = R.string.ssl_error_expired; + break; + case SslError.SSL_DATE_INVALID: + case SslError.SSL_IDMISMATCH: + case SslError.SSL_NOTYETVALID: + case SslError.SSL_UNTRUSTED: + errorMessage = R.string.ssl_error_cert; + break; + case SslError.SSL_INVALID: + default: + errorMessage = R.string.ssl_error_generic; + break; + } + + if(AppConfig.getInstance(mainActivity).sslToastErrorsEnabled) + Toast.makeText(mainActivity, errorMessage, Toast.LENGTH_LONG).show(); + String finalErrorMessage = mainActivity.getString(errorMessage) + " - Error url: " + error.getUrl() + " - Source page: " + webviewUrl; + GNLog.getInstance().logError(TAG, finalErrorMessage, new Exception(finalErrorMessage), GNLog.TYPE_TOAST_ERROR); + } + + @SuppressWarnings("unused") + public String getCurrentWebviewUrl() { + return currentWebviewUrl; + } + + public void setCurrentWebviewUrl(String currentWebviewUrl) { + this.currentWebviewUrl = currentWebviewUrl; + ((GoNativeApplication) mainActivity.getApplication()).mBridge.setCurrentWebviewUrl(currentWebviewUrl); + } + + public WebResourceResponse interceptHtml(LeanWebView view, String url) { +// Log.d(TAG, "intercept " + url); + return htmlIntercept.interceptHtml(view, url, this.currentWebviewUrl); + } + + @SuppressWarnings("UnusedReturnValue") + public boolean chooseFileUpload(String[] mimetypespec) { + return chooseFileUpload(mimetypespec, false); + } + + public boolean chooseFileUpload(final String[] mimetypespec, final boolean multiple) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + chooseFileUploadAfterPermission(mimetypespec, multiple); + } else { + List permissionToRequest = new ArrayList<>(); + permissionToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE); + permissionToRequest.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); + if (!Utils.isPermissionGranted(mainActivity, Manifest.permission.CAMERA)) { + permissionToRequest.add(Manifest.permission.CAMERA); + } + mainActivity.getPermission(permissionToRequest.toArray(new String[0]), (permissions, grantResults) -> chooseFileUploadAfterPermission(mimetypespec, multiple)); + } + return true; + } + + @SuppressWarnings("UnusedReturnValue") + private boolean chooseFileUploadAfterPermission(String[] mimetypespec, boolean multiple) { + mainActivity.setDirectUploadImageUri(null); + + FileUploadIntentsCreator creator = new FileUploadIntentsCreator(mainActivity, mimetypespec, multiple); + + if (!Utils.isPermissionGranted(mainActivity, Manifest.permission.CAMERA) && (creator.canUploadImage() || creator.canUploadVideo())) { + mainActivity.getPermission(new String[]{Manifest.permission.CAMERA}, (permissions, grantResults) -> launchChooserIntent(creator)); + return true; + } + + return launchChooserIntent(creator); + } + + private boolean launchChooserIntent(FileUploadIntentsCreator creator) { + Intent intentToSend = creator.chooserIntent(); + mainActivity.setDirectUploadImageUri(creator.getCurrentCaptureUri()); + + try { + mainActivity.startActivityForResult(intentToSend, MainActivity.REQUEST_SELECT_FILE); + return true; + } catch (ActivityNotFoundException e) { + mainActivity.cancelFileUpload(); + Toast.makeText(mainActivity, R.string.cannot_open_file_chooser, Toast.LENGTH_LONG).show(); + return false; + } + } + + + public boolean openDirectCamera(final String[] mimetypespec, final boolean multiple) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (!Utils.isPermissionGranted(mainActivity, Manifest.permission.CAMERA)) { + mainActivity.getPermission(new String[]{Manifest.permission.CAMERA}, (permissions, grantResults) -> openDirectCameraAfterPermission(mimetypespec, multiple)); + } else { + openDirectCameraAfterPermission(mimetypespec, multiple); + } + } else { + + ArrayList permissionRequests = new ArrayList<>(); + permissionRequests.add(Manifest.permission.READ_EXTERNAL_STORAGE); + permissionRequests.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); + + if (!Utils.isPermissionGranted(mainActivity, Manifest.permission.CAMERA)) { + permissionRequests.add(Manifest.permission.CAMERA); + } + + mainActivity.getPermission(permissionRequests.toArray(new String[0]), (permissions, grantResults) -> openDirectCameraAfterPermission(mimetypespec, multiple)); + } + return true; + } + + /* + Directly opens camera if the mime types are images. If not, run existing default process + */ + @SuppressWarnings("UnusedReturnValue") + private boolean openDirectCameraAfterPermission(String[] mimetypespec, boolean multiple) { + + // Check and verify CAMERA permission so app will not crash when using cam + if (!Utils.isPermissionGranted(mainActivity, Manifest.permission.CAMERA)) { + Toast.makeText(mainActivity, R.string.upload_camera_permission_denied, Toast.LENGTH_SHORT).show(); + mainActivity.cancelFileUpload(); + return false; + } + + mainActivity.setDirectUploadImageUri(null); + + FileUploadIntentsCreator creator = new FileUploadIntentsCreator(mainActivity, mimetypespec, multiple); + Intent intentToSend = creator.cameraIntent(); + mainActivity.setDirectUploadImageUri(creator.getCurrentCaptureUri()); + + try { + // Directly open the camera intent with the same Request Result value value + mainActivity.startActivityForResult(intentToSend, MainActivity.REQUEST_SELECT_FILE); + return true; + } catch (ActivityNotFoundException e) { + mainActivity.cancelFileUpload(); + Toast.makeText(mainActivity, R.string.cannot_open_file_chooser, Toast.LENGTH_LONG).show(); + } + + return false; + } + + @SuppressLint("SetJavaScriptEnabled") + public void createNewWindow(WebView webView, Message resultMsg) { + AppConfig appConfig = AppConfig.getInstance(mainActivity); + + if (appConfig.maxWindowsEnabled && appConfig.numWindows > 0 && mainActivity.getGNWindowManager().getWindowCount() >= appConfig.numWindows) { + // All of these just to get new url + WebView newWebView = new WebView(webView.getContext()); + WebView.WebViewTransport transport = (WebView.WebViewTransport) resultMsg.obj; + transport.setWebView(newWebView); + resultMsg.sendToTarget(); + newWebView.setWebViewClient(new WebViewClient() { + @Override + public void onPageFinished(WebView view, String url) { + if (!mainActivity.onMaxWindowsReached(url)) { + Intent intent = new Intent(mainActivity.getBaseContext(), MainActivity.class); + intent.putExtra("isRoot", false); + intent.putExtra("url", url); + intent.putExtra(MainActivity.EXTRA_IGNORE_INTERCEPT_MAXWINDOWS, true); + mainActivity.startActivityForResult(intent, MainActivity.REQUEST_WEB_ACTIVITY); + } + } + }); + return; + } + createNewWindow(resultMsg, appConfig.maxWindowsEnabled); + } + + private void createNewWindow(Message resultMsg, boolean maxWindowsEnabled) { + ((GoNativeApplication) mainActivity.getApplication()).setWebviewMessage(resultMsg); + Intent intent = new Intent(mainActivity.getBaseContext(), MainActivity.class); + intent.putExtra("isRoot", false); + intent.putExtra(MainActivity.EXTRA_WEBVIEW_WINDOW_OPEN, true); + + if (maxWindowsEnabled) { + intent.putExtra(MainActivity.EXTRA_IGNORE_INTERCEPT_MAXWINDOWS, true); + } + + // need to use startActivityForResult instead of startActivity because of singleTop launch mode + mainActivity.startActivityForResult(intent, MainActivity.REQUEST_WEB_ACTIVITY); + } + + public boolean isLocationServiceEnabled() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + LocationManager lm = mainActivity.getSystemService(LocationManager.class); + return lm.isLocationEnabled(); + } else { + // This is Deprecated in API 28 + int mode = Settings.Secure.getInt(mainActivity.getContentResolver(), Settings.Secure.LOCATION_MODE, + Settings.Secure.LOCATION_MODE_OFF); + return (mode != Settings.Secure.LOCATION_MODE_OFF); + } + } + + protected void onDownloadStart() { + startLoadTimeout.removeCallbacksAndMessages(null); + state = WebviewLoadState.STATE_DONE; + } + + + private static class GetKeyTask extends AsyncTask> { + private Activity activity; + private ClientCertRequest request; + + public GetKeyTask(Activity activity, ClientCertRequest request) { + this.activity = activity; + this.request = request; + } + + @Override + protected Pair doInBackground(String... strings) { + String alias = strings[0]; + + try { + PrivateKey privateKey = KeyChain.getPrivateKey(activity, alias); + X509Certificate[] certificates = KeyChain.getCertificateChain(activity, alias); + return new Pair<>(privateKey, certificates); + } catch (Exception e) { + GNLog.getInstance().logError(TAG, "Error getting private key for alias " + alias, e); + return null; + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @Override + protected void onPostExecute(Pair result) { + if (result != null && result.first != null & result.second != null) { + request.proceed(result.first, result.second); + } else { + request.ignore(); + } + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public void onReceivedClientCertRequest(String url, ClientCertRequest request) { + Uri uri = Uri.parse(url); + KeyChainAliasCallback callback = alias -> { + if (alias == null) { + request.ignore(); + return; + } + + new GetKeyTask(mainActivity, request).execute(alias); + }; + + KeyChain.choosePrivateKeyAlias(mainActivity, callback, request.getKeyTypes(), request.getPrincipals(), request.getHost(), + request.getPort(), null); + } + + // Cancels scheduled display of offline page after timeout + public void cancelLoadTimeout() { + if (startLoadTimeout == null && state != WebviewLoadState.STATE_START_LOAD) return; + startLoadTimeout.removeCallbacksAndMessages(null); + showWebViewImmediately(); + } +} diff --git a/app/src/main/java/co/median/android/WebViewPool.java b/app/src/main/java/co/median/android/WebViewPool.java new file mode 100644 index 0000000..842b811 --- /dev/null +++ b/app/src/main/java/co/median/android/WebViewPool.java @@ -0,0 +1,278 @@ +package co.median.android; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Point; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import android.util.Pair; +import android.view.Display; +import android.view.WindowManager; +import android.webkit.WebResourceResponse; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import co.median.median_core.AppConfig; +import co.median.median_core.GoNativeWebviewInterface; + +/** + * Created by Weiyin He on 9/3/14. + * Copyright 2014 GoNative.io LLC + */ + +public class WebViewPool { + public class WebViewPoolCallback { + @SuppressWarnings("unused") + public void onPageFinished(final GoNativeWebviewInterface webview, String url) { + WebViewPool pool = WebViewPool.this; + + pool.urlToWebview.put(pool.currentLoadingUrl, pool.currentLoadingWebview); + pool.currentLoadingUrl = null; + pool.currentLoadingWebview = null; + pool.isLoading = false; + pool.htmlIntercept.setInterceptUrl(null); + + pool.resumeLoading(); + } + + public WebResourceResponse interceptHtml(GoNativeWebviewInterface webview, String url) { + return htmlIntercept.interceptHtml(webview, url, null); + } + } + + private Activity context; + private HtmlIntercept htmlIntercept; + + private boolean isInitialized; + private Map urlToWebview; + private Map urlToDisownPolicy; + + private WebViewPoolCallback webViewPoolCallback = new WebViewPoolCallback(); + + private List> urlSets; + private Set urlsToLoad; + private GoNativeWebviewInterface currentLoadingWebview; + private String currentLoadingUrl; + private boolean isLoading; + private String lastUrlRequest; + private boolean isMainActivityLoading; + + public void init(Activity activity) { + if (this.isInitialized) return; + this.isInitialized = true; + + // webviews must be instantiated from activity context + this.context = activity; + this.htmlIntercept = new HtmlIntercept(activity); + + this.urlToWebview = new HashMap<>(); + this.urlToDisownPolicy = new HashMap<>(); + this.urlSets = new ArrayList<>(); + this.urlsToLoad = new HashSet<>(); + + // register for broadcast messages + BroadcastReceiver messageReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) return; + + switch (intent.getAction()) { + case UrlNavigation.STARTED_LOADING_MESSAGE: { + WebViewPool pool = WebViewPool.this; + pool.isMainActivityLoading = true; + if (pool.currentLoadingWebview != null) { + // onReceive is always called on the main thread, so this is safe. + pool.currentLoadingWebview.stopLoading(); + pool.isLoading = false; + } + break; + } + case UrlNavigation.FINISHED_LOADING_MESSAGE: { + WebViewPool pool = WebViewPool.this; + pool.isMainActivityLoading = false; + pool.resumeLoading(); + break; + } + case AppConfig.PROCESSED_WEBVIEW_POOLS_MESSAGE: + processConfig(); + break; + case UrlNavigation.CLEAR_POOLS_MESSAGE: + WebViewPool.this.flushAll(); + break; + } + } + }; + LocalBroadcastManager.getInstance(this.context).registerReceiver( + messageReceiver, new IntentFilter(UrlNavigation.STARTED_LOADING_MESSAGE)); + LocalBroadcastManager.getInstance(this.context).registerReceiver( + messageReceiver, new IntentFilter(UrlNavigation.FINISHED_LOADING_MESSAGE)); + LocalBroadcastManager.getInstance(this.context).registerReceiver( + messageReceiver, new IntentFilter(UrlNavigation.CLEAR_POOLS_MESSAGE)); + LocalBroadcastManager.getInstance(this.context).registerReceiver( + messageReceiver, new IntentFilter(AppConfig.PROCESSED_WEBVIEW_POOLS_MESSAGE)); + + processConfig(); + } + + private void processConfig() { + JSONArray config = AppConfig.getInstance(this.context).webviewPools; + if (config == null) { + return; + } + + for (int i = 0; i < config.length(); i++) { + JSONObject entry = config.optJSONObject(i); + if (entry != null) { + JSONArray urls = entry.optJSONArray("urls"); + if (urls != null) { + HashSet urlSet = new HashSet<>(); + for (int j = 0; j < urls.length(); j++) { + if (urls.isNull(j)) continue; + String urlString = null; + WebViewPoolDisownPolicy policy = WebViewPoolDisownPolicy.defaultPolicy; + + Object urlEntry = urls.opt(j); + if (urlEntry instanceof String) urlString = (String)urlEntry; + + if (urlString == null && urlEntry instanceof JSONObject) { + urlString = ((JSONObject)urlEntry).optString("url"); + String policyString = AppConfig.optString((JSONObject)urlEntry, "disown"); + if (policyString != null) { + if (policyString.equalsIgnoreCase("reload")) + policy = WebViewPoolDisownPolicy.Reload; + else if (policyString.equalsIgnoreCase("never")) + policy = WebViewPoolDisownPolicy.Never; + else if (policyString.equalsIgnoreCase("always")) + policy = WebViewPoolDisownPolicy.Always; + } + } + + if (urlString != null) { + urlSet.add(urlString); + this.urlToDisownPolicy.put(urlString, policy); + } + } + + this.urlSets.add(urlSet); + } + + } + } + + // if config changed, we may have to load webviews corresponding to the previously requested url + if (this.lastUrlRequest != null) { + webviewForUrl(this.lastUrlRequest); + } + + resumeLoading(); + } + + private void resumeLoading() { + if (this.isMainActivityLoading || this.isLoading) return; + + if (this.currentLoadingWebview != null && this.currentLoadingUrl != null) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + currentLoadingWebview.loadUrl(currentLoadingUrl); + } + }); + this.isLoading = true; + return; + } + + if (!this.urlsToLoad.isEmpty()) { + final String urlString = this.urlsToLoad.iterator().next(); + this.currentLoadingUrl = urlString; + this.htmlIntercept.setInterceptUrl(urlString); + + context.runOnUiThread(new Runnable() { + @Override + public void run() { + LeanWebView webview = new LeanWebView(context); + currentLoadingWebview = webview; + urlsToLoad.remove(urlString); + WebViewSetup.setupWebview(webview, context); + + // size it before loading url + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + if (wm != null) { + Display display = wm.getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + webview.layout(0, 0, size.x, size.y); + } + + new PoolWebViewClient(webViewPoolCallback, webview); + + currentLoadingWebview = webview; + urlsToLoad.remove(urlString); + + currentLoadingWebview.loadUrl(urlString); + } + }); + } + } + + private void flushAll() { + if (this.currentLoadingWebview != null) this.currentLoadingWebview.stopLoading(); + this.isLoading = false; + this.currentLoadingWebview = null; + this.currentLoadingUrl = null; + this.lastUrlRequest = null; + this.urlToWebview.clear(); + } + + public void disownWebview(GoNativeWebviewInterface webview) { + Iterator it = this.urlToWebview.keySet().iterator(); + while(it.hasNext()) { + String key = it.next(); + if (this.urlToWebview.get(key) == webview) { + it.remove(); + this.urlsToLoad.add(key); + } + } + } + + public Pair webviewForUrl(String url) { + this.lastUrlRequest = url; + HashSet urlSet = urlSetForUrl(url); + if (urlSet.size() > 0) { + HashSet newUrls = new HashSet<> (urlSet); + if (this.currentLoadingUrl != null) { + newUrls.remove(this.currentLoadingUrl); + } + newUrls.removeAll(this.urlToWebview.keySet()); + + this.urlsToLoad.addAll(newUrls); + } + + GoNativeWebviewInterface webview = this.urlToWebview.get(url); + if (webview == null) return new Pair<>(null, null); + + WebViewPoolDisownPolicy policy = this.urlToDisownPolicy.get(url); + return new Pair<>(webview, policy); + } + + private HashSet urlSetForUrl(String url){ + HashSet result = new HashSet<>(); + for (Set set : this.urlSets) { + if (set.contains(url)) { + result.addAll(set); + } + } + return result; + } + +} diff --git a/app/src/main/java/co/median/android/WebViewPoolDisownPolicy.java b/app/src/main/java/co/median/android/WebViewPoolDisownPolicy.java new file mode 100644 index 0000000..e75b75b --- /dev/null +++ b/app/src/main/java/co/median/android/WebViewPoolDisownPolicy.java @@ -0,0 +1,10 @@ +package co.median.android; + +/** + * Created by weiyin on 9/3/14. + */ +public enum WebViewPoolDisownPolicy { + Always, Reload, Never; + + public static WebViewPoolDisownPolicy defaultPolicy = WebViewPoolDisownPolicy.Reload; +} diff --git a/app/src/main/java/co/median/android/files/CapturedImageSaver.kt b/app/src/main/java/co/median/android/files/CapturedImageSaver.kt new file mode 100644 index 0000000..2e19911 --- /dev/null +++ b/app/src/main/java/co/median/android/files/CapturedImageSaver.kt @@ -0,0 +1,46 @@ +package co.median.android.files + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + + +class CapturedImageSaver { + fun saveCapturedBitmap(context: Context, bitmapUri: Uri): Uri? { + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val imageFileName = "IMG_$timeStamp.jpg" + + val resolver: ContentResolver = context.contentResolver + val currentUri = if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + val contentValues = ContentValues() + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, imageFileName) + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/*") + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + } else { + val storageDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES) + val captureFile = File(storageDir, imageFileName) + FileProvider.getUriForFile(context, context.applicationContext.packageName + ".fileprovider", captureFile); + } + + currentUri?.let { + context.contentResolver.openOutputStream(it).use { output -> + context.contentResolver.openInputStream(bitmapUri).use { input -> + output?.write(input?.readBytes()) + } + } + } + + return currentUri + } +} \ No newline at end of file diff --git a/app/src/main/java/co/median/android/widget/CircleImageView.java b/app/src/main/java/co/median/android/widget/CircleImageView.java new file mode 100644 index 0000000..bd255c5 --- /dev/null +++ b/app/src/main/java/co/median/android/widget/CircleImageView.java @@ -0,0 +1,142 @@ +package co.median.android.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RadialGradient; +import android.graphics.Shader; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.view.View; +import android.view.animation.Animation; + +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; + +/** + * Private class created to work around issues with AnimationListeners being + * called before the animation is actually complete and support shadows on older + * platforms. + */ +class CircleImageView extends androidx.appcompat.widget.AppCompatImageView { + + private static final int KEY_SHADOW_COLOR = 0x1E000000; + private static final int FILL_SHADOW_COLOR = 0x3D000000; + // PX + private static final float X_OFFSET = 0f; + private static final float Y_OFFSET = 1.75f; + private static final float SHADOW_RADIUS = 3.5f; + private static final int SHADOW_ELEVATION = 4; + + private Animation.AnimationListener mListener; + int mShadowRadius; + + CircleImageView(Context context, int color) { + super(context); + final float density = getContext().getResources().getDisplayMetrics().density; + final int shadowYOffset = (int) (density * Y_OFFSET); + final int shadowXOffset = (int) (density * X_OFFSET); + + mShadowRadius = (int) (density * SHADOW_RADIUS); + + ShapeDrawable circle; + if (elevationSupported()) { + circle = new ShapeDrawable(new OvalShape()); + ViewCompat.setElevation(this, SHADOW_ELEVATION * density); + } else { + OvalShape oval = new CircleImageView.OvalShadow(mShadowRadius); + circle = new ShapeDrawable(oval); + setLayerType(View.LAYER_TYPE_SOFTWARE, circle.getPaint()); + circle.getPaint().setShadowLayer(mShadowRadius, shadowXOffset, shadowYOffset, + KEY_SHADOW_COLOR); + final int padding = mShadowRadius; + // set padding so the inner image sits correctly within the shadow. + setPadding(padding, padding, padding, padding); + } + circle.getPaint().setColor(color); + ViewCompat.setBackground(this, circle); + } + + private boolean elevationSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (!elevationSupported()) { + setMeasuredDimension(getMeasuredWidth() + mShadowRadius * 2, getMeasuredHeight() + + mShadowRadius * 2); + } + } + + public void setAnimationListener(Animation.AnimationListener listener) { + mListener = listener; + } + + @Override + public void onAnimationStart() { + super.onAnimationStart(); + if (mListener != null) { + mListener.onAnimationStart(getAnimation()); + } + } + + @Override + public void onAnimationEnd() { + super.onAnimationEnd(); + if (mListener != null) { + mListener.onAnimationEnd(getAnimation()); + } + } + + /** + * Update the background color of the circle image view. + * + * @param colorRes Id of a color resource. + */ + public void setBackgroundColorRes(int colorRes) { + setBackgroundColor(ContextCompat.getColor(getContext(), colorRes)); + } + + @Override + public void setBackgroundColor(int color) { + if (getBackground() instanceof ShapeDrawable) { + ((ShapeDrawable) getBackground()).getPaint().setColor(color); + } + } + + private class OvalShadow extends OvalShape { + private RadialGradient mRadialGradient; + private Paint mShadowPaint; + + OvalShadow(int shadowRadius) { + super(); + mShadowPaint = new Paint(); + mShadowRadius = shadowRadius; + updateRadialGradient((int) rect().width()); + } + + @Override + protected void onResize(float width, float height) { + super.onResize(width, height); + updateRadialGradient((int) width); + } + + @Override + public void draw(Canvas canvas, Paint paint) { + final int viewWidth = CircleImageView.this.getWidth(); + final int viewHeight = CircleImageView.this.getHeight(); + canvas.drawCircle(viewWidth / 2, viewHeight / 2, viewWidth / 2, mShadowPaint); + canvas.drawCircle(viewWidth / 2, viewHeight / 2, viewWidth / 2 - mShadowRadius, paint); + } + + private void updateRadialGradient(int diameter) { + mRadialGradient = new RadialGradient(diameter / 2, diameter / 2, + mShadowRadius, new int[] { FILL_SHADOW_COLOR, Color.TRANSPARENT }, + null, Shader.TileMode.CLAMP); + mShadowPaint.setShader(mRadialGradient); + } + } +} diff --git a/app/src/main/java/co/median/android/widget/GoNativeDrawerLayout.java b/app/src/main/java/co/median/android/widget/GoNativeDrawerLayout.java new file mode 100644 index 0000000..cb30ba5 --- /dev/null +++ b/app/src/main/java/co/median/android/widget/GoNativeDrawerLayout.java @@ -0,0 +1,50 @@ +package co.median.android.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.drawerlayout.widget.DrawerLayout; + +public class GoNativeDrawerLayout extends DrawerLayout { + + private boolean disableTouch = false; + + public GoNativeDrawerLayout(@NonNull Context context) { + this(context, null); + } + + public GoNativeDrawerLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public GoNativeDrawerLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (disableTouch) { + Log.d("SWIPE", "GNDrawerLayout disabled touch"); + return false; + } + return super.onInterceptTouchEvent(ev); + } + + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (disableTouch) { + Log.d("SWIPE", "GNDrawerLayout disabled touch"); + return false; + } + return super.onTouchEvent(ev); + } + + public void setDisableTouch(boolean disableTouch){ + this.disableTouch = disableTouch; + } +} diff --git a/app/src/main/java/co/median/android/widget/GoNativeSwipeRefreshLayout.java b/app/src/main/java/co/median/android/widget/GoNativeSwipeRefreshLayout.java new file mode 100644 index 0000000..6c342a0 --- /dev/null +++ b/app/src/main/java/co/median/android/widget/GoNativeSwipeRefreshLayout.java @@ -0,0 +1,1222 @@ +package co.median.android.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Transformation; +import android.widget.AbsListView; +import android.widget.ListView; + +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.annotation.VisibleForTesting; +import androidx.core.content.ContextCompat; +import androidx.core.view.NestedScrollingChild; +import androidx.core.view.NestedScrollingChildHelper; +import androidx.core.view.NestedScrollingParent; +import androidx.core.view.NestedScrollingParentHelper; +import androidx.core.view.ViewCompat; +import androidx.core.widget.ListViewCompat; +import androidx.swiperefreshlayout.widget.CircularProgressDrawable; + +import co.median.median_core.GNLog; + +/** + * The SwipeRefreshLayout should be used whenever the user can refresh the + * contents of a view via a vertical swipe gesture. The activity that + * instantiates this view should add an OnRefreshListener to be notified + * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout + * will notify the listener each and every time the gesture is completed again; + * the listener is responsible for correctly determining when to actually + * initiate a refresh of its content. If the listener determines there should + * not be a refresh, it must call setRefreshing(false) to cancel any visual + * indication of a refresh. If an activity wishes to show just the progress + * animation, it should call setRefreshing(true). To disable the gesture and + * progress animation, call setEnabled(false) on the view. + *

+ * This layout should be made the parent of the view that will be refreshed as a + * result of the gesture and can only support one direct child. This view will + * also be made the target of the gesture and will be forced to match both the + * width and the height supplied in this layout. The SwipeRefreshLayout does not + * provide accessibility events; instead, a menu item must be provided to allow + * refresh of the content wherever this gesture is used. + *

+ */ +public class GoNativeSwipeRefreshLayout extends ViewGroup implements NestedScrollingParent, + NestedScrollingChild { + // Maps to ProgressBar.Large style + public static final int LARGE = CircularProgressDrawable.LARGE; + // Maps to ProgressBar default style + public static final int DEFAULT = CircularProgressDrawable.DEFAULT; + + public static final int DEFAULT_SLINGSHOT_DISTANCE = -1; + + @VisibleForTesting + static final int CIRCLE_DIAMETER = 40; + @VisibleForTesting + static final int CIRCLE_DIAMETER_LARGE = 56; + + private static final String LOG_TAG = GoNativeSwipeRefreshLayout.class.getSimpleName(); + + private static final int MAX_ALPHA = 255; + private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA); + + private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; + private static final int INVALID_POINTER = -1; + private static final float DRAG_RATE = .5f; + + // Max amount of circle that can be filled by progress during swipe gesture, + // where 1.0 is a full circle + private static final float MAX_PROGRESS_ANGLE = .8f; + + private static final int SCALE_DOWN_DURATION = 150; + + private static final int ALPHA_ANIMATION_DURATION = 300; + + private static final int ANIMATE_TO_TRIGGER_DURATION = 200; + + private static final int ANIMATE_TO_START_DURATION = 200; + + // Default background for the progress spinner + private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA; + // Default offset in dips from the top of the view to where the progress spinner should stop + private static final int DEFAULT_CIRCLE_TARGET = 64; + + private View mTarget; // the target of the gesture + GoNativeSwipeRefreshLayout.OnRefreshListener mListener; + boolean mRefreshing = false; + private int mTouchSlop; + private float mTotalDragDistance = -1; + + // If nested scrolling is enabled, the total amount that needed to be + // consumed by this as the nested scrolling parent is used in place of the + // overscroll determined by MOVE events in the onTouch handler + private float mTotalUnconsumed; + private final NestedScrollingParentHelper mNestedScrollingParentHelper; + private final NestedScrollingChildHelper mNestedScrollingChildHelper; + private final int[] mParentScrollConsumed = new int[2]; + private final int[] mParentOffsetInWindow = new int[2]; + private boolean mNestedScrollInProgress; + + private int mMediumAnimationDuration; + int mCurrentTargetOffsetTop; + + private float mInitialMotionY; + private float mInitialDownY; + private float mInitialDownX; + private boolean mIsBeingDragged; + private int mActivePointerId = INVALID_POINTER; + // Whether this item is scaled up rather than clipped + boolean mScale; + + // Target is returning to its start offset because it was cancelled or a + // refresh was triggered. + private boolean mReturningToStart; + private final DecelerateInterpolator mDecelerateInterpolator; + private static final int[] LAYOUT_ATTRS = new int[] { + android.R.attr.enabled + }; + + CircleImageView mCircleView; + private int mCircleViewIndex = -1; + + protected int mFrom; + + float mStartingScale; + + protected int mOriginalOffsetTop; + + int mSpinnerOffsetEnd; + + int mCustomSlingshotDistance; + + CircularProgressDrawable mProgress; + + private Animation mScaleAnimation; + + private Animation mScaleDownAnimation; + + private Animation mAlphaStartAnimation; + + private Animation mAlphaMaxAnimation; + + private Animation mScaleDownToStartAnimation; + + boolean mNotify; + + private int mCircleDiameter; + + // Whether the client has set a custom starting position; + boolean mUsingCustomStart; + + private GoNativeSwipeRefreshLayout.OnChildScrollUpCallback mChildScrollUpCallback; + + private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + if (mRefreshing) { + // Make sure the progress view is fully visible + mProgress.setAlpha(MAX_ALPHA); + mProgress.start(); + if (mNotify) { + if (mListener != null) { + mListener.onRefresh(); + } + } + mCurrentTargetOffsetTop = mCircleView.getTop(); + } else { + reset(); + } + } + }; + + void reset() { + mCircleView.clearAnimation(); + mProgress.stop(); + mCircleView.setVisibility(View.GONE); + setColorViewAlpha(MAX_ALPHA); + // Return the circle to its start position + if (mScale) { + setAnimationProgress(0 /* animation complete and view is hidden */); + } else { + setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCurrentTargetOffsetTop); + } + mCurrentTargetOffsetTop = mCircleView.getTop(); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + if (!enabled) { + reset(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + reset(); + } + + private void setColorViewAlpha(int targetAlpha) { + mCircleView.getBackground().setAlpha(targetAlpha); + mProgress.setAlpha(targetAlpha); + } + + /** + * The refresh indicator starting and resting position is always positioned + * near the top of the refreshing content. This position is a consistent + * location, but can be adjusted in either direction based on whether or not + * there is a toolbar or actionbar present. + *

+ * Note: Calling this will reset the position of the refresh indicator to + * start. + *

+ * + * @param scale Set to true if there is no view at a higher z-order than where the progress + * spinner is set to appear. Setting it to true will cause indicator to be scaled + * up rather than clipped. + * @param start The offset in pixels from the top of this view at which the + * progress spinner should appear. + * @param end The offset in pixels from the top of this view at which the + * progress spinner should come to rest after a successful swipe + * gesture. + */ + public void setProgressViewOffset(boolean scale, int start, int end) { + mScale = scale; + mOriginalOffsetTop = start; + mSpinnerOffsetEnd = end; + mUsingCustomStart = true; + reset(); + mRefreshing = false; + } + + /** + * @return The offset in pixels from the top of this view at which the progress spinner should + * appear. + */ + public int getProgressViewStartOffset() { + return mOriginalOffsetTop; + } + + /** + * @return The offset in pixels from the top of this view at which the progress spinner should + * come to rest after a successful swipe gesture. + */ + public int getProgressViewEndOffset() { + return mSpinnerOffsetEnd; + } + + /** + * The refresh indicator resting position is always positioned near the top + * of the refreshing content. This position is a consistent location, but + * can be adjusted in either direction based on whether or not there is a + * toolbar or actionbar present. + * + * @param scale Set to true if there is no view at a higher z-order than where the progress + * spinner is set to appear. Setting it to true will cause indicator to be scaled + * up rather than clipped. + * @param end The offset in pixels from the top of this view at which the + * progress spinner should come to rest after a successful swipe + * gesture. + */ + public void setProgressViewEndTarget(boolean scale, int end) { + mSpinnerOffsetEnd = end; + mScale = scale; + mCircleView.invalidate(); + } + + /** + * Sets a custom slingshot distance. + * + * @param slingshotDistance The distance in pixels that the refresh indicator can be pulled + * beyond its resting position. Use + * {@link #DEFAULT_SLINGSHOT_DISTANCE} to reset to the default value. + * + */ + public void setSlingshotDistance(@Px int slingshotDistance) { + mCustomSlingshotDistance = slingshotDistance; + } + + /** + * One of DEFAULT, or LARGE. + */ + public void setSize(int size) { + if (size != CircularProgressDrawable.LARGE && size != CircularProgressDrawable.DEFAULT) { + return; + } + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + if (size == CircularProgressDrawable.LARGE) { + mCircleDiameter = (int) (CIRCLE_DIAMETER_LARGE * metrics.density); + } else { + mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); + } + // force the bounds of the progress circle inside the circle view to + // update by setting it to null before updating its size and then + // re-setting it + mCircleView.setImageDrawable(null); + mProgress.setStyle(size); + mCircleView.setImageDrawable(mProgress); + } + + /** + * Simple constructor to use when creating a SwipeRefreshLayout from code. + * + * @param context + */ + public GoNativeSwipeRefreshLayout(@NonNull Context context) { + this(context, null); + } + + /** + * Constructor that is called when inflating SwipeRefreshLayout from XML. + * + * @param context + * @param attrs + */ + public GoNativeSwipeRefreshLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + + mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + + mMediumAnimationDuration = getResources().getInteger( + android.R.integer.config_mediumAnimTime); + + setWillNotDraw(false); + mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); + + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); + + createProgressView(); + setChildrenDrawingOrderEnabled(true); + // the absolute offset has to take into account that the circle starts at an offset + mSpinnerOffsetEnd = (int) (DEFAULT_CIRCLE_TARGET * metrics.density); + mTotalDragDistance = mSpinnerOffsetEnd; + mNestedScrollingParentHelper = new NestedScrollingParentHelper(this); + + mNestedScrollingChildHelper = new NestedScrollingChildHelper(this); + setNestedScrollingEnabled(true); + + mOriginalOffsetTop = mCurrentTargetOffsetTop = -mCircleDiameter; + moveToStart(1.0f); + + final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); + setEnabled(a.getBoolean(0, true)); + a.recycle(); + } + + @Override + protected int getChildDrawingOrder(int childCount, int i) { + if (mCircleViewIndex < 0) { + return i; + } else if (i == childCount - 1) { + // Draw the selected child last + return mCircleViewIndex; + } else if (i >= mCircleViewIndex) { + // Move the children after the selected child earlier one + return i + 1; + } else { + // Keep the children before the selected child the same + return i; + } + } + + private void createProgressView() { + mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT); + mProgress = new CircularProgressDrawable(getContext()); + mProgress.setStyle(CircularProgressDrawable.DEFAULT); + mCircleView.setImageDrawable(mProgress); + mCircleView.setVisibility(View.GONE); + addView(mCircleView); + } + + /** + * Set the listener to be notified when a refresh is triggered via the swipe + * gesture. + */ + public void setOnRefreshListener(@Nullable GoNativeSwipeRefreshLayout.OnRefreshListener listener) { + mListener = listener; + } + + /** + * Notify the widget that refresh state has changed. Do not call this when + * refresh is triggered by a swipe gesture. + * + * @param refreshing Whether or not the view should show refresh progress. + */ + public void setRefreshing(boolean refreshing) { + if (refreshing && mRefreshing != refreshing) { + // scale and show + mRefreshing = refreshing; + int endTarget = 0; + if (!mUsingCustomStart) { + endTarget = mSpinnerOffsetEnd + mOriginalOffsetTop; + } else { + endTarget = mSpinnerOffsetEnd; + } + setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop); + mNotify = false; + startScaleUpAnimation(mRefreshListener); + } else { + setRefreshing(refreshing, false /* notify */); + } + } + + private void startScaleUpAnimation(Animation.AnimationListener listener) { + mCircleView.setVisibility(View.VISIBLE); + mProgress.setAlpha(MAX_ALPHA); + mScaleAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + setAnimationProgress(interpolatedTime); + } + }; + mScaleAnimation.setDuration(mMediumAnimationDuration); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleAnimation); + } + + /** + * Pre API 11, this does an alpha animation. + * @param progress + */ + void setAnimationProgress(float progress) { + mCircleView.setScaleX(progress); + mCircleView.setScaleY(progress); + } + + private void setRefreshing(boolean refreshing, final boolean notify) { + if (mRefreshing != refreshing) { + mNotify = notify; + ensureTarget(); + mRefreshing = refreshing; + if (mRefreshing) { + animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); + } else { + startScaleDownAnimation(mRefreshListener); + } + } + } + + void startScaleDownAnimation(Animation.AnimationListener listener) { + mScaleDownAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + setAnimationProgress(1 - interpolatedTime); + } + }; + mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION); + mCircleView.setAnimationListener(listener); + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleDownAnimation); + } + + private void startProgressAlphaStartAnimation() { + mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA); + } + + private void startProgressAlphaMaxAnimation() { + mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA); + } + + private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) { + Animation alpha = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + mProgress.setAlpha( + (int) (startingAlpha + ((endingAlpha - startingAlpha) * interpolatedTime))); + } + }; + alpha.setDuration(ALPHA_ANIMATION_DURATION); + // Clear out the previous animation listeners. + mCircleView.setAnimationListener(null); + mCircleView.clearAnimation(); + mCircleView.startAnimation(alpha); + return alpha; + } + + /** + * @deprecated Use {@link #setProgressBackgroundColorSchemeResource(int)} + */ + @Deprecated + public void setProgressBackgroundColor(int colorRes) { + setProgressBackgroundColorSchemeResource(colorRes); + } + + /** + * Set the background color of the progress spinner disc. + * + * @param colorRes Resource id of the color. + */ + public void setProgressBackgroundColorSchemeResource(@ColorRes int colorRes) { + setProgressBackgroundColorSchemeColor(ContextCompat.getColor(getContext(), colorRes)); + } + + /** + * Set the background color of the progress spinner disc. + * + * @param color + */ + public void setProgressBackgroundColorSchemeColor(@ColorInt int color) { + mCircleView.setBackgroundColor(color); + } + + /** + * @deprecated Use {@link #setColorSchemeResources(int...)} + */ + @Deprecated + public void setColorScheme(@ColorRes int... colors) { + setColorSchemeResources(colors); + } + + /** + * Set the color resources used in the progress animation from color resources. + * The first color will also be the color of the bar that grows in response + * to a user swipe gesture. + * + * @param colorResIds + */ + public void setColorSchemeResources(@ColorRes int... colorResIds) { + final Context context = getContext(); + int[] colorRes = new int[colorResIds.length]; + for (int i = 0; i < colorResIds.length; i++) { + colorRes[i] = ContextCompat.getColor(context, colorResIds[i]); + } + setColorSchemeColors(colorRes); + } + + /** + * Set the colors used in the progress animation. The first + * color will also be the color of the bar that grows in response to a user + * swipe gesture. + * + * @param colors + */ + public void setColorSchemeColors(@ColorInt int... colors) { + ensureTarget(); + mProgress.setColorSchemeColors(colors); + } + + /** + * @return Whether the SwipeRefreshWidget is actively showing refresh + * progress. + */ + public boolean isRefreshing() { + return mRefreshing; + } + + private void ensureTarget() { + // Don't bother getting the parent height if the parent hasn't been laid + // out yet. + if (mTarget == null) { + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + if (!child.equals(mCircleView)) { + mTarget = child; + break; + } + } + } + } + + /** + * Set the distance to trigger a sync in dips + * + * @param distance + */ + public void setDistanceToTriggerSync(int distance) { + mTotalDragDistance = distance; + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final int width = getMeasuredWidth(); + final int height = getMeasuredHeight(); + if (getChildCount() == 0) { + return; + } + if (mTarget == null) { + ensureTarget(); + } + if (mTarget == null) { + return; + } + final View child = mTarget; + final int childLeft = getPaddingLeft(); + final int childTop = getPaddingTop(); + final int childWidth = width - getPaddingLeft() - getPaddingRight(); + final int childHeight = height - getPaddingTop() - getPaddingBottom(); + child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); + int circleWidth = mCircleView.getMeasuredWidth(); + int circleHeight = mCircleView.getMeasuredHeight(); + mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, + (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (mTarget == null) { + ensureTarget(); + } + if (mTarget == null) { + return; + } + mTarget.measure(MeasureSpec.makeMeasureSpec( + getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), + MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( + getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); + mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY)); + mCircleViewIndex = -1; + // Get the index of the circleview. + for (int index = 0; index < getChildCount(); index++) { + if (getChildAt(index) == mCircleView) { + mCircleViewIndex = index; + break; + } + } + } + + /** + * Get the diameter of the progress circle that is displayed as part of the + * swipe to refresh layout. + * + * @return Diameter in pixels of the progress circle view. + */ + public int getProgressCircleDiameter() { + return mCircleDiameter; + } + + /** + * @return Whether it is possible for the child view of this layout to + * scroll up. Override this if the child view is a custom view. + */ + public boolean canChildScrollUp() { + if (mChildScrollUpCallback != null) { + return mChildScrollUpCallback.canChildScrollUp(this, mTarget); + } + if (mTarget instanceof ListView) { + return ListViewCompat.canScrollList((ListView) mTarget, -1); + } + return mTarget.canScrollVertically(-1); + } + + /** + * Set a callback to override {@link GoNativeSwipeRefreshLayout#canChildScrollUp()} method. Non-null + * callback will return the value provided by the callback and ignore all internal logic. + * @param callback Callback that should be called when canChildScrollUp() is called. + */ + public void setOnChildScrollUpCallback(@Nullable GoNativeSwipeRefreshLayout.OnChildScrollUpCallback callback) { + mChildScrollUpCallback = callback; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + ensureTarget(); + + final int action = ev.getActionMasked(); + int pointerIndex; + + if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { + mReturningToStart = false; + } + + if (!isEnabled() || mReturningToStart || canChildScrollUp() + || mRefreshing || mNestedScrollInProgress || ev.getPointerCount() > 1) { + // Fail fast if we're not in a state where a swipe is possible + return false; + } + + switch (action) { + case MotionEvent.ACTION_DOWN: + setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop()); + mActivePointerId = ev.getPointerId(0); + mIsBeingDragged = false; + + pointerIndex = ev.findPointerIndex(mActivePointerId); + if (pointerIndex < 0) { + return false; + } + mInitialDownY = ev.getY(pointerIndex); + mInitialDownX = ev.getX(pointerIndex); + break; + + case MotionEvent.ACTION_MOVE: + if (mActivePointerId == INVALID_POINTER) { + GNLog.getInstance().logError(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id."); + return false; + } + + pointerIndex = ev.findPointerIndex(mActivePointerId); + if (pointerIndex < 0) { + return false; + } + + // START: modification + + float diffX = ev.getX() - mInitialDownX; + float diffY = ev.getY() - mInitialDownY; + if (Math.abs(diffY) < Math.abs(diffX)) { + // horizontal scroll + break; + } + + // END: modification + + final float y = ev.getY(pointerIndex); + startDragging(y); + break; + + case MotionEvent.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsBeingDragged = false; + mActivePointerId = INVALID_POINTER; + break; + } + + return mIsBeingDragged; + } + + @Override + public void requestDisallowInterceptTouchEvent(boolean b) { + // if this is a List < L or another view that doesn't support nested + // scrolling, ignore this request so that the vertical scroll event + // isn't stolen + if ((android.os.Build.VERSION.SDK_INT < 21 && mTarget instanceof AbsListView) + || (mTarget != null && !ViewCompat.isNestedScrollingEnabled(mTarget))) { + // Nope. + } else { + super.requestDisallowInterceptTouchEvent(b); + } + } + + // NestedScrollingParent + + @Override + public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { + return isEnabled() && !mReturningToStart && !mRefreshing + && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; + } + + @Override + public void onNestedScrollAccepted(View child, View target, int axes) { + // Reset the counter of how much leftover scroll needs to be consumed. + mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes); + // Dispatch up to the nested parent + startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL); + mTotalUnconsumed = 0; + mNestedScrollInProgress = true; + } + + @Override + public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { + // If we are in the middle of consuming, a scroll, then we want to move the spinner back up + // before allowing the list to scroll + if (dy > 0 && mTotalUnconsumed > 0) { + if (dy > mTotalUnconsumed) { + consumed[1] = dy - (int) mTotalUnconsumed; + mTotalUnconsumed = 0; + } else { + mTotalUnconsumed -= dy; + consumed[1] = dy; + } + moveSpinner(mTotalUnconsumed); + } + + // If a client layout is using a custom start position for the circle + // view, they mean to hide it again before scrolling the child view + // If we get back to mTotalUnconsumed == 0 and there is more to go, hide + // the circle so it isn't exposed if its blocking content is moved + if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0 + && Math.abs(dy - consumed[1]) > 0) { + mCircleView.setVisibility(View.GONE); + } + + // Now let our nested parent consume the leftovers + final int[] parentConsumed = mParentScrollConsumed; + if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) { + consumed[0] += parentConsumed[0]; + consumed[1] += parentConsumed[1]; + } + } + + @Override + public int getNestedScrollAxes() { + return mNestedScrollingParentHelper.getNestedScrollAxes(); + } + + @Override + public void onStopNestedScroll(View target) { + mNestedScrollingParentHelper.onStopNestedScroll(target); + mNestedScrollInProgress = false; + // Finish the spinner for nested scrolling if we ever consumed any + // unconsumed nested scroll + if (mTotalUnconsumed > 0) { + finishSpinner(mTotalUnconsumed); + mTotalUnconsumed = 0; + } + // Dispatch up our nested parent + stopNestedScroll(); + } + + @Override + public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed, + final int dxUnconsumed, final int dyUnconsumed) { + // Dispatch up to the nested parent first + dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, + mParentOffsetInWindow); + + // This is a bit of a hack. Nested scrolling works from the bottom up, and as we are + // sometimes between two nested scrolling views, we need a way to be able to know when any + // nested scrolling parent has stopped handling events. We do that by using the + // 'offset in window 'functionality to see if we have been moved from the event. + // This is a decent indication of whether we should take over the event stream or not. + final int dy = dyUnconsumed + mParentOffsetInWindow[1]; + if (dy < 0 && !canChildScrollUp()) { + mTotalUnconsumed += Math.abs(dy); + moveSpinner(mTotalUnconsumed); + } + } + + // NestedScrollingChild + + @Override + public void setNestedScrollingEnabled(boolean enabled) { + mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled); + } + + @Override + public boolean isNestedScrollingEnabled() { + return mNestedScrollingChildHelper.isNestedScrollingEnabled(); + } + + @Override + public boolean startNestedScroll(int axes) { + return mNestedScrollingChildHelper.startNestedScroll(axes); + } + + @Override + public void stopNestedScroll() { + mNestedScrollingChildHelper.stopNestedScroll(); + } + + @Override + public boolean hasNestedScrollingParent() { + return mNestedScrollingChildHelper.hasNestedScrollingParent(); + } + + @Override + public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, int[] offsetInWindow) { + return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, + dxUnconsumed, dyUnconsumed, offsetInWindow); + } + + @Override + public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { + return mNestedScrollingChildHelper.dispatchNestedPreScroll( + dx, dy, consumed, offsetInWindow); + } + + @Override + public boolean onNestedPreFling(View target, float velocityX, + float velocityY) { + return dispatchNestedPreFling(velocityX, velocityY); + } + + @Override + public boolean onNestedFling(View target, float velocityX, float velocityY, + boolean consumed) { + return dispatchNestedFling(velocityX, velocityY, consumed); + } + + @Override + public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { + return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); + } + + @Override + public boolean dispatchNestedPreFling(float velocityX, float velocityY) { + return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY); + } + + private boolean isAnimationRunning(Animation animation) { + return animation != null && animation.hasStarted() && !animation.hasEnded(); + } + + private void moveSpinner(float overscrollTop) { + mProgress.setArrowEnabled(true); + float originalDragPercent = overscrollTop / mTotalDragDistance; + + float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); + float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; + float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; + float slingshotDist = mCustomSlingshotDistance > 0 + ? mCustomSlingshotDistance + : (mUsingCustomStart + ? mSpinnerOffsetEnd - mOriginalOffsetTop + : mSpinnerOffsetEnd); + float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2) + / slingshotDist); + float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow( + (tensionSlingshotPercent / 4), 2)) * 2f; + float extraMove = (slingshotDist) * tensionPercent * 2; + + int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove); + // where 1.0f is a full circle + if (mCircleView.getVisibility() != View.VISIBLE) { + mCircleView.setVisibility(View.VISIBLE); + } + if (!mScale) { + mCircleView.setScaleX(1f); + mCircleView.setScaleY(1f); + } + + if (mScale) { + setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance)); + } + if (overscrollTop < mTotalDragDistance) { + if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA + && !isAnimationRunning(mAlphaStartAnimation)) { + // Animate the alpha + startProgressAlphaStartAnimation(); + } + } else { + if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) { + // Animate the alpha + startProgressAlphaMaxAnimation(); + } + } + float strokeStart = adjustedPercent * .8f; + mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart)); + mProgress.setArrowScale(Math.min(1f, adjustedPercent)); + + float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; + mProgress.setProgressRotation(rotation); + setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop); + } + + private void finishSpinner(float overscrollTop) { + if (overscrollTop > mTotalDragDistance) { + setRefreshing(true, true /* notify */); + } else { + // cancel refresh + mRefreshing = false; + mProgress.setStartEndTrim(0f, 0f); + Animation.AnimationListener listener = null; + if (!mScale) { + listener = new Animation.AnimationListener() { + + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + if (!mScale) { + startScaleDownAnimation(null); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + }; + } + animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener); + mProgress.setArrowEnabled(false); + } + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + final int action = ev.getActionMasked(); + int pointerIndex = -1; + + if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { + mReturningToStart = false; + } + + if (!isEnabled() || mReturningToStart || canChildScrollUp() + || mRefreshing || mNestedScrollInProgress) { + // Fail fast if we're not in a state where a swipe is possible + return false; + } + + switch (action) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = ev.getPointerId(0); + mIsBeingDragged = false; + break; + + case MotionEvent.ACTION_MOVE: { + pointerIndex = ev.findPointerIndex(mActivePointerId); + if (pointerIndex < 0) { + GNLog.getInstance().logError(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); + return false; + } + + // START: modification + + float diffX = ev.getX() - mInitialDownX; + float diffY = ev.getY() - mInitialDownY; + if (Math.abs(diffY) < Math.abs(diffX)) { + // horizontal scroll + break; + } + + // END: modification + + final float y = ev.getY(pointerIndex); + startDragging(y); + + if (mIsBeingDragged) { + final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; + if (overscrollTop > 0) { + moveSpinner(overscrollTop); + } else { + return false; + } + } + break; + } + case MotionEvent.ACTION_POINTER_DOWN: { + pointerIndex = ev.getActionIndex(); + if (pointerIndex < 0) { + GNLog.getInstance().logError(LOG_TAG, + "Got ACTION_POINTER_DOWN event but have an invalid action index."); + return false; + } + mActivePointerId = ev.getPointerId(pointerIndex); + break; + } + + case MotionEvent.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + + case MotionEvent.ACTION_UP: { + pointerIndex = ev.findPointerIndex(mActivePointerId); + if (pointerIndex < 0) { + GNLog.getInstance().logError(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id."); + return false; + } + + if (mIsBeingDragged) { + final float y = ev.getY(pointerIndex); + final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; + mIsBeingDragged = false; + finishSpinner(overscrollTop); + } + mActivePointerId = INVALID_POINTER; + return false; + } + case MotionEvent.ACTION_CANCEL: + return false; + } + + return true; + } + + private void startDragging(float y) { + final float yDiff = y - mInitialDownY; + if (yDiff > mTouchSlop && !mIsBeingDragged) { + mInitialMotionY = mInitialDownY + mTouchSlop; + mIsBeingDragged = true; + mProgress.setAlpha(STARTING_PROGRESS_ALPHA); + } + } + + private void animateOffsetToCorrectPosition(int from, Animation.AnimationListener listener) { + mFrom = from; + mAnimateToCorrectPosition.reset(); + mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); + mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mAnimateToCorrectPosition); + } + + private void animateOffsetToStartPosition(int from, Animation.AnimationListener listener) { + if (mScale) { + // Scale the item back down + startScaleDownReturnToStartAnimation(from, listener); + } else { + mFrom = from; + mAnimateToStartPosition.reset(); + mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION); + mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mAnimateToStartPosition); + } + } + + private final Animation mAnimateToCorrectPosition = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + int targetTop = 0; + int endTarget = 0; + if (!mUsingCustomStart) { + endTarget = mSpinnerOffsetEnd - Math.abs(mOriginalOffsetTop); + } else { + endTarget = mSpinnerOffsetEnd; + } + targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); + int offset = targetTop - mCircleView.getTop(); + setTargetOffsetTopAndBottom(offset); + mProgress.setArrowScale(1 - interpolatedTime); + } + }; + + void moveToStart(float interpolatedTime) { + int targetTop = 0; + targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime)); + int offset = targetTop - mCircleView.getTop(); + setTargetOffsetTopAndBottom(offset); + } + + private final Animation mAnimateToStartPosition = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + moveToStart(interpolatedTime); + } + }; + + private void startScaleDownReturnToStartAnimation(int from, + Animation.AnimationListener listener) { + mFrom = from; + mStartingScale = mCircleView.getScaleX(); + mScaleDownToStartAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + float targetScale = (mStartingScale + (-mStartingScale * interpolatedTime)); + setAnimationProgress(targetScale); + moveToStart(interpolatedTime); + } + }; + mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleDownToStartAnimation); + } + + void setTargetOffsetTopAndBottom(int offset) { + mCircleView.bringToFront(); + ViewCompat.offsetTopAndBottom(mCircleView, offset); + mCurrentTargetOffsetTop = mCircleView.getTop(); + } + + private void onSecondaryPointerUp(MotionEvent ev) { + final int pointerIndex = ev.getActionIndex(); + final int pointerId = ev.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mActivePointerId = ev.getPointerId(newPointerIndex); + } + } + + /** + * Classes that wish to be notified when the swipe gesture correctly + * triggers a refresh should implement this interface. + */ + public interface OnRefreshListener { + /** + * Called when a swipe gesture triggers a refresh. + */ + void onRefresh(); + } + + /** + * Classes that wish to override {@link GoNativeSwipeRefreshLayout#canChildScrollUp()} method + * behavior should implement this interface. + */ + public interface OnChildScrollUpCallback { + /** + * Callback that will be called when {@link GoNativeSwipeRefreshLayout#canChildScrollUp()} method + * is called to allow the implementer to override its behavior. + * + * @param parent SwipeRefreshLayout that this callback is overriding. + * @param child The child view of SwipeRefreshLayout. + * + * @return Whether it is possible for the child view of parent layout to scroll up. + */ + boolean canChildScrollUp(@NonNull GoNativeSwipeRefreshLayout parent, @Nullable View child); + } +} diff --git a/app/src/main/java/co/median/android/widget/HandleView.kt b/app/src/main/java/co/median/android/widget/HandleView.kt new file mode 100644 index 0000000..c497d04 --- /dev/null +++ b/app/src/main/java/co/median/android/widget/HandleView.kt @@ -0,0 +1,155 @@ +package co.median.android.widget + +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.core.content.res.ResourcesCompat +import co.median.android.R + +class HandleView : RelativeLayout { + private val iconView: ImageView + private val textView: TextView + + init { + inflate(context, R.layout.view_handle, this) + iconView = findViewById(R.id.icon) + textView = findViewById(R.id.text) + } + + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 + ) : super(context, attrs, defStyle) { + context.theme.obtainStyledAttributes(attrs, R.styleable.HandleView, 0, 0).apply { + val backgroundDrawable = getDrawable(R.styleable.HandleView_handleBackground) + ?: ResourcesCompat.getDrawable( + resources, + R.drawable.shape_rounded, + context.theme + ) + val iconDrawable = getDrawable(R.styleable.HandleView_iconDrawable) + val text = getString(R.styleable.HandleView_text) + val inactiveColor = getColor(R.styleable.HandleView_inactiveColor, inactiveColor) + val activeColor = getColor(R.styleable.HandleView_activeColor, activeColor) + initView(backgroundDrawable, iconDrawable, text, inactiveColor, activeColor) + } + } + + constructor( + context: Context, + backgroundDrawable: Drawable?, + iconDrawable: Drawable?, + text: String?, + @ColorInt inactiveColor: Int, + @ColorInt activeColor: Int, + ) : super(context, null, 0) { + initView(backgroundDrawable, iconDrawable, text, inactiveColor, activeColor) + } + + var maxTextWidth: Int = Int.MIN_VALUE + var inactiveColor: Int = Color.WHITE + var activeColor: Int = Color.WHITE + + fun initView( + backgroundDrawable: Drawable?, + iconDrawable: Drawable?, + text: String?, + @ColorInt inactiveColor: Int, + @ColorInt activeColor: Int + ) { + background = backgroundDrawable + iconView.setImageDrawable(iconDrawable) + setText(text) + textView.layoutParams.let { + it.width = 0 + textView.layoutParams = it + } + + this.inactiveColor = inactiveColor + this.activeColor = activeColor + iconView.setColorFilter(inactiveColor) + } + + fun setText(text: String?) { + textView.layoutParams.width = LayoutParams.WRAP_CONTENT + textView.text = text + textView.measure( + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + ) + maxTextWidth = textView.measuredWidth + textView.layoutParams.let { + it.width = 0 + textView.layoutParams = it + } + } + + fun animateShowText() { + if (textView.text.isEmpty()) { + return + } + if (textView.layoutParams.width != 0) { + textView.layoutParams.let { + it.width = 0 + textView.layoutParams = it + } + } + val animator = ValueAnimator.ofInt(0, maxTextWidth) + animator.addUpdateListener { anim -> + val value = anim.animatedValue as Int + textView.layoutParams.let { + it.width = value + textView.layoutParams = it + } + } + animator.duration = 300 + animator.start() + } + + fun animateHideText() { + if (textView.text.isEmpty()) { + return + } + val animator = ValueAnimator.ofInt(maxTextWidth, 0) + animator.duration = 300 + animator.addUpdateListener { anim -> + val value = anim.animatedValue as Int + val params = textView.layoutParams + params.width = value + textView.layoutParams = params + } + animator.start() + } + + fun animateActive() { + val animator = ValueAnimator.ofObject(ArgbEvaluator(), inactiveColor, activeColor) + animator.addUpdateListener { anim -> + val color = anim.animatedValue as Int + textView.setTextColor(color) + iconView.setColorFilter(color, PorterDuff.Mode.SRC_IN) + } + animator.duration = 100 + animator.start() + } + + fun animateInactive() { + val animator = ValueAnimator.ofObject(ArgbEvaluator(), activeColor, inactiveColor) + animator.addUpdateListener { anim -> + val color = anim.animatedValue as Int + textView.setTextColor(color) + iconView.setColorFilter(color, PorterDuff.Mode.SRC_IN) + } + animator.duration = 200 + animator.start() + } +} \ No newline at end of file diff --git a/app/src/main/java/co/median/android/widget/SwipeHistoryNavigationLayout.kt b/app/src/main/java/co/median/android/widget/SwipeHistoryNavigationLayout.kt new file mode 100644 index 0000000..23eaeea --- /dev/null +++ b/app/src/main/java/co/median/android/widget/SwipeHistoryNavigationLayout.kt @@ -0,0 +1,474 @@ +package co.median.android.widget; + +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.widget.EdgeEffect +import android.widget.FrameLayout +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.ViewCompat +import co.median.android.R +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.min + +class SwipeHistoryNavigationLayout : FrameLayout { + private val leftHandleView: HandleView + private val rightHandleView: HandleView + private val rightEdgeEffect: EdgeEffect + + // Styleable properties + private val iconWidth: Float = resources.getDimension(R.dimen.handle_icon_size) + private val iconWidthInDp: Float = iconWidth / (context.resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT) + private val backgroundDrawable: Drawable? + private val leftEdgeDrawable: Drawable? + private val rightEdgeDrawable: Drawable? + private val firstText: String + private val inactiveColor: Int + private var activeColor: Int + // end Styleable properties + + private var leftHandleFirstPos: Float = Float.NaN + private var rightHandleFirstPos: Float = Float.NaN + + /** + * Left edge touch detection width. + */ + private var leftEdgeWidth = Float.NaN + + /** + * Right edge touch detection width. + */ + private var rightEdgeWidth = Float.NaN + + /** + * Swipeable width. + */ + private var swipeableWidth = Float.NaN + + /** + * Percentage of screen edges to be judged. + */ + private var edgePer = 5 / 100f + + /** + * Ratio of swipeable width to screen width.. + */ + private var swipeablePer = 16 / 100f + + /** + * Swipe distance threshold before triggering. + */ + private var swipeTriggerThreshold = 80f + + /** + * Swipe distance threshold from edge to calculate diagonal motion. + */ + private var swipeEdgeThreshold = 30f + + private var firstTouchX: Int = Int.MIN_VALUE + private var isSwipingLeftEdge = false + private var isSwipingRightEdge = false + private var isTouchInProgress = false + + private var lastTouchX: Float = Float.NaN + private var oldDeltaX: Float = Float.NaN + private var deltaX: Float = Float.NaN + private var isSwipeReachesLimit = false + + private var pointX = 0f + private var pointY = 0f + private var inMotion = false + + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 + ) : super(context, attrs, defStyle) { + context.theme.obtainStyledAttributes(attrs, R.styleable.SwipeHistoryNavigationLayout, 0, 0) + .apply { + backgroundDrawable = + getDrawable(R.styleable.SwipeHistoryNavigationLayout_handleBackground) + leftEdgeDrawable = + getDrawable(R.styleable.SwipeHistoryNavigationLayout_leftHandleDrawable) + ?: ResourcesCompat.getDrawable( + resources, + R.drawable.ic_baseline_arrow_back_24, + context.theme + ) + rightEdgeDrawable = + getDrawable(R.styleable.SwipeHistoryNavigationLayout_rightHandleDrawable) + ?: ResourcesCompat.getDrawable( + resources, + R.drawable.ic_baseline_arrow_forward_24, + context.theme + ) + firstText = + getString(R.styleable.SwipeHistoryNavigationLayout_leftHandleLabel) ?: "" + inactiveColor = getColor( + R.styleable.SwipeHistoryNavigationLayout_inactiveColor, + ResourcesCompat.getColor(resources, R.color.swipe_nav_inactive, context.theme) + ) + activeColor = getColor( + R.styleable.SwipeHistoryNavigationLayout_activeColor, + ResourcesCompat.getColor(resources, R.color.swipe_nav_active, context.theme) + ) + } + + leftHandleView = HandleView( + context, + backgroundDrawable, + leftEdgeDrawable, + firstText, + inactiveColor, + activeColor + ) + rightHandleView = HandleView( + context, + backgroundDrawable, + rightEdgeDrawable, + "", + inactiveColor, + activeColor + ) + rightEdgeEffect = EdgeEffect(context) + setWillNotDraw(false) + } + + @SuppressLint("RtlHardcoded") + override fun onFinishInflate() { + super.onFinishInflate() + val leftParams = LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, + Gravity.CENTER_VERTICAL or Gravity.LEFT + ) + addView(leftHandleView, leftParams) + addView( + rightHandleView, LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, + Gravity.CENTER_VERTICAL + ) + ) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + if (changed) { + leftHandleView.let { + leftHandleFirstPos = -iconWidth + it.translationX = leftHandleFirstPos + } + rightHandleView.let { + rightHandleFirstPos = width + iconWidth + it.translationX = rightHandleFirstPos + } + + leftEdgeWidth = width.toFloat() * edgePer + rightEdgeWidth = width - leftEdgeWidth + swipeableWidth = width.toFloat() * swipeablePer + } + } + + override fun isNestedScrollingEnabled(): Boolean { + return true + } + + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + if (!swipeNavListener.isSwipeEnabled()) { + return false + } + + when (ev?.action) { + MotionEvent.ACTION_DOWN -> { + inMotion = false + pointX = ev.x + pointY = ev.y + + if (isLeftEdge(ev.x) && swipeNavListener.canSwipeLeftEdge()) { + isSwipingLeftEdge = true + firstTouchX = ev.x.toInt() + leftEdgeGrabbed() + } else if (isRightEdge(ev.x) && swipeNavListener.canSwipeRightEdge()) { + isSwipingRightEdge = true + firstTouchX = width + rightEdgeGrabbed() + } + } + MotionEvent.ACTION_MOVE -> { + + val diffX = abs(pointX - ev.x) + val diffY = abs(pointY - ev.y) + + if (isTouchInProgress) { + return true + } + + return if ((isSwipingLeftEdge || isSwipingRightEdge) && ((diffX > swipeEdgeThreshold) || (diffY > swipeEdgeThreshold)) && !inMotion) { + inMotion = true + val angle = atan2(diffY, diffX) + if (angle > Math.PI/6) { + false + } else { + isTouchInProgress = true + parent.requestDisallowInterceptTouchEvent(true) + true + } + } else { + false + } + } + MotionEvent.ACTION_UP -> { + pointX = 0f + pointY = 0f + isSwipingLeftEdge = false + isSwipingRightEdge = false + if (isTouchInProgress) { + return true + } + } + } + + return super.onInterceptTouchEvent(ev) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(ev: MotionEvent?): Boolean { + var needsInvalidate = false + when (ev?.action) { + MotionEvent.ACTION_MOVE -> { + lastTouchX = ev.x + oldDeltaX = deltaX + deltaX = abs(lastTouchX - firstTouchX) + + if (isSwipingLeftEdge && swipeNavListener.isSwipeEnabled() && (deltaX >= swipeEdgeThreshold)) { + moveLeftHandle() + } else if (isSwipingRightEdge && swipeNavListener.isSwipeEnabled() && (deltaX >= swipeEdgeThreshold)) { + if (swipeNavListener.canSwipeRightEdge()) { + moveRightHandle() + } else if (deltaX > oldDeltaX) { + val over = abs(deltaX - oldDeltaX) + rightEdgeEffect.onPull(over / width) + needsInvalidate = true + } + } + + if (deltaX > (swipeableWidth + swipeTriggerThreshold + iconWidthInDp)) { + if (!isSwipeReachesLimit) { + isSwipeReachesLimit = true + swipeReachesLimit() + } + } else { + if (isSwipeReachesLimit) { + isSwipeReachesLimit = false + leaveHandle() + } + } + } + MotionEvent.ACTION_UP -> { + needsInvalidate = releaseSwipe() + parent.requestDisallowInterceptTouchEvent(false) + } + } + + if (needsInvalidate) { + ViewCompat.postInvalidateOnAnimation(this); + } + + return super.onTouchEvent(ev) + } + + + private fun isLeftEdge(x: Float) = x <= leftEdgeWidth + private fun isRightEdge(x: Float) = x >= rightEdgeWidth + + private fun isTouchedEdge(ev: MotionEvent?): Boolean { + // Do not intercept the edges when edge swiping is disabled + + return ev?.action == MotionEvent.ACTION_DOWN && ( + (isLeftEdge(ev.x) && swipeNavListener.canSwipeLeftEdge()) + || (isRightEdge(ev.x) && swipeNavListener.canSwipeRightEdge())) + && swipeNavListener.isSwipeEnabled() + } + + private fun moveLeftHandle() { + leftHandleView.let { + val value = (deltaX - swipeEdgeThreshold) - firstTouchX - iconWidth + it.translationX = min(value, swipeableWidth - iconWidth) + } + } + + private fun moveRightHandle() { + rightHandleView.let { + val value = firstTouchX - (deltaX - swipeEdgeThreshold) + iconWidth / 2 + it.translationX = max(value, width - swipeableWidth) + } + } + + private fun leftEdgeGrabbed() { + leftHandleView.setText(swipeNavListener.getGoBackLabel()) + } + + private fun rightEdgeGrabbed() { + } + + private fun releaseSwipe(): Boolean { + rightEdgeEffect.onRelease() + + if (isSwipingLeftEdge) { + if (isSwipeReachesLimit) { + leaveHandle() + swipeNavListener.navigateBack() + } + leftHandleView.let { + val animator = ObjectAnimator.ofFloat( + it, + View.TRANSLATION_X, + it.translationX, + leftHandleFirstPos + ) + animator.duration = 400 + animator.start() + } + } else if (isSwipingRightEdge) { + if (isSwipeReachesLimit) { + leaveHandle() + swipeNavListener.navigateForward() + } + rightHandleView.let { + val animator = ObjectAnimator.ofFloat( + it, + View.TRANSLATION_X, + it.translationX, + rightHandleFirstPos + ) + animator.duration = 400 + animator.start() + } + } + isSwipingLeftEdge = false + isSwipingRightEdge = false + isSwipeReachesLimit = false + isTouchInProgress = false + return rightEdgeEffect.isFinished + } + + private fun swipeReachesLimit() { + if (isSwipingLeftEdge && swipeNavListener.canSwipeLeftEdge()) { + swipeNavListener.leftSwipeReachesLimit() + leftHandleView.animateActive() + leftHandleView.animateShowText() + } else if (isSwipingRightEdge && swipeNavListener.canSwipeRightEdge()) { + swipeNavListener.rightSwipeReachesLimit() + rightHandleView.animateActive() + rightHandleView.animateShowText() + } + } + + private fun leaveHandle() { + if (isSwipingLeftEdge) { + leftHandleView.animateInactive() + leftHandleView.animateHideText() + } else if (isSwipingRightEdge) { + rightHandleView.animateInactive() + rightHandleView.animateHideText() + } + } + + fun setActiveColor(color: Int) { + activeColor = color; + rightHandleView.activeColor = color; + leftHandleView.activeColor = color; + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + var needsInvalidate = false + if (overScrollMode == OVER_SCROLL_ALWAYS || overScrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS) { + if (!rightEdgeEffect.isFinished) { + canvas.let { + val restoreCount: Int = canvas.save() + val width: Int = width + val height: Int = height - paddingTop - paddingBottom + + canvas.rotate(90f) + canvas.translate(paddingTop.toFloat(), -width.toFloat()) + rightEdgeEffect.setSize(height, width) + needsInvalidate = needsInvalidate or rightEdgeEffect.draw(canvas) + canvas.restoreToCount(restoreCount) + } + + } + } else { + rightEdgeEffect.finish() + } + if (needsInvalidate) { + // Keep animating + ViewCompat.postInvalidateOnAnimation(this); + } + } + + var swipeNavListener: OnSwipeNavListener = object : OnSwipeNavListener { + override fun canSwipeLeftEdge(): Boolean = true + override fun canSwipeRightEdge(): Boolean = true + override fun getGoBackLabel(): String = "" + override fun navigateBack(): Boolean = true + override fun navigateForward(): Boolean = true + override fun leftSwipeReachesLimit() {} + override fun rightSwipeReachesLimit() {} + override fun isSwipeEnabled(): Boolean = true + } + + interface OnSwipeNavListener { + /** + * Return true if left-edge swipe is to be enabled. + */ + fun canSwipeLeftEdge(): Boolean + + /** + * Return true if right-edge swipe is to be enabled. + */ + fun canSwipeRightEdge(): Boolean + + /** + * Called when you grab the left edge. + * Text to be displayed when swiping to the limit. + */ + fun getGoBackLabel(): String + + /** + * Implement the page back operation. + */ + fun navigateBack(): Boolean + + /** + * Implement the page forward operation. + */ + fun navigateForward(): Boolean + + /** + * Called when the movement of the left-edge swipe reaches its limit. + */ + fun leftSwipeReachesLimit() + + /** + * Called when the movement of the right-edge swipe reaches its limit. + */ + fun rightSwipeReachesLimit() + + /** + * Return true if swipe edge to navigate is enabled + */ + fun isSwipeEnabled(): Boolean + } +} \ No newline at end of file diff --git a/app/src/main/java/co/median/android/widget/WebViewContainerView.java b/app/src/main/java/co/median/android/widget/WebViewContainerView.java new file mode 100644 index 0000000..61ebeee --- /dev/null +++ b/app/src/main/java/co/median/android/widget/WebViewContainerView.java @@ -0,0 +1,81 @@ +package co.median.android.widget; + +import android.app.Activity; +import android.content.Context; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import co.median.android.LeanWebView; +import co.median.android.MainActivity; +import co.median.android.WebViewSetup; +import co.median.median_core.AppConfig; +import co.median.median_core.Bridge; +import co.median.median_core.GoNativeWebviewInterface; +import co.median.android.GoNativeApplication; + +public class WebViewContainerView extends FrameLayout { + + private ViewGroup webview; + private boolean isGecko = false; + + public WebViewContainerView(@NonNull Context context) { + super(context); + } + + public WebViewContainerView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initializeWebview(context); + } + + public WebViewContainerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initializeWebview(context); + } + + private void initializeWebview(Context context) { + AppConfig appConfig = AppConfig.getInstance(context); + + if (appConfig.geckoViewEnabled) { + try { + Class classGecko = Class.forName("co.median.plugins.android.gecko.GNGeckoView"); + Constructor consGecko = classGecko.getConstructor(Context.class); + webview = (ViewGroup) consGecko.newInstance(context); + this.isGecko = true; + } catch (Exception e) { + e.printStackTrace(); + } + } else { + webview = new LeanWebView(context); + } + ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + webview.setLayoutParams(layoutParams); + this.addView(webview); + } + + public void setupWebview(MainActivity activity, boolean isRoot) { + if (isGecko) { + try { + Class geckoSetupClass = Class.forName("co.median.plugins.android.gecko.WebViewSetup"); + Method setupWebview = geckoSetupClass.getMethod("setupWebviewForActivity", Activity.class, GoNativeWebviewInterface.class, Bridge.class, boolean.class); + setupWebview.invoke(geckoSetupClass, activity, (GoNativeWebviewInterface) webview, ((GoNativeApplication) activity.getApplication()).mBridge, isRoot); + } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + e.printStackTrace(); + } + } else { + WebViewSetup.setupWebviewForActivity(getWebview(), activity); + } + } + + public GoNativeWebviewInterface getWebview() { + return (GoNativeWebviewInterface) webview; + } + +} diff --git a/app/src/main/res/anim/fast_fade_out.xml b/app/src/main/res/anim/fast_fade_out.xml new file mode 100644 index 0000000..c1d89d7 --- /dev/null +++ b/app/src/main/res/anim/fast_fade_out.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/drawable-hdpi/drawer_shadow.9.png b/app/src/main/res/drawable-hdpi/drawer_shadow.9.png new file mode 100644 index 0000000..236bff5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/drawer_shadow.9.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_actionbar.png b/app/src/main/res/drawable-hdpi/ic_actionbar.png new file mode 100644 index 0000000..de636fe Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_chevron_down_light.png b/app/src/main/res/drawable-hdpi/ic_chevron_down_light.png new file mode 100644 index 0000000..8f31418 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_chevron_down_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_chevron_up_light.png b/app/src/main/res/drawable-hdpi/ic_chevron_up_light.png new file mode 100644 index 0000000..a6c48c2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_chevron_up_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_notification.png b/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000..0cbe8ba Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_refresh_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_refresh_black_24dp.png new file mode 100644 index 0000000..27cdded Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_refresh_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_refresh_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_refresh_white_24dp.png new file mode 100644 index 0000000..1e0668b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_refresh_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_search_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_search_black_24dp.png new file mode 100644 index 0000000..85bbd67 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_search_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..e5f77de Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_share_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_share_black_24dp.png new file mode 100644 index 0000000..55c3f05 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_share_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_share_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_share_white_24dp.png new file mode 100644 index 0000000..f48872a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_share_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/splash.png b/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..a54cc4b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/app/src/main/res/drawable-mdpi/drawer_shadow.9.png b/app/src/main/res/drawable-mdpi/drawer_shadow.9.png new file mode 100644 index 0000000..ffe3a28 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/drawer_shadow.9.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_actionbar.png b/app/src/main/res/drawable-mdpi/ic_actionbar.png new file mode 100644 index 0000000..351b9cc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_chevron_down_light.png b/app/src/main/res/drawable-mdpi/ic_chevron_down_light.png new file mode 100644 index 0000000..b192827 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_chevron_down_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_chevron_up_light.png b/app/src/main/res/drawable-mdpi/ic_chevron_up_light.png new file mode 100644 index 0000000..2ab27fd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_chevron_up_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_notification.png b/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..74efd40 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_refresh_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_refresh_black_24dp.png new file mode 100644 index 0000000..de3711b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_refresh_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_refresh_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_refresh_white_24dp.png new file mode 100644 index 0000000..9feafa0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_refresh_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_search_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_search_black_24dp.png new file mode 100644 index 0000000..48b1504 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_search_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..aefa2bd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_share_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_share_black_24dp.png new file mode 100644 index 0000000..3a20780 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_share_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_share_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_share_white_24dp.png new file mode 100644 index 0000000..9b6621c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_share_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/splash.png b/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..3fb038e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_actionbar.png b/app/src/main/res/drawable-night-hdpi/ic_actionbar.png new file mode 100644 index 0000000..166cd9a Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-night-hdpi/splash.png b/app/src/main/res/drawable-night-hdpi/splash.png new file mode 100644 index 0000000..a54cc4b Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/splash.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_actionbar.png b/app/src/main/res/drawable-night-mdpi/ic_actionbar.png new file mode 100644 index 0000000..606741d Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-night-mdpi/splash.png b/app/src/main/res/drawable-night-mdpi/splash.png new file mode 100644 index 0000000..3fb038e Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/splash.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_actionbar.png b/app/src/main/res/drawable-night-xhdpi/ic_actionbar.png new file mode 100644 index 0000000..c5e0508 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/splash.png b/app/src/main/res/drawable-night-xhdpi/splash.png new file mode 100644 index 0000000..2cc195f Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/splash.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_actionbar.png b/app/src/main/res/drawable-night-xxhdpi/ic_actionbar.png new file mode 100644 index 0000000..677d6fb Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/splash.png b/app/src/main/res/drawable-night-xxhdpi/splash.png new file mode 100644 index 0000000..9340324 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/splash.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_actionbar.png b/app/src/main/res/drawable-night-xxxhdpi/ic_actionbar.png new file mode 100644 index 0000000..a639e5b Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/splash.png b/app/src/main/res/drawable-night-xxxhdpi/splash.png new file mode 100644 index 0000000..54fdc80 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/splash.png differ diff --git a/app/src/main/res/drawable-xhdpi/drawer_shadow.9.png b/app/src/main/res/drawable-xhdpi/drawer_shadow.9.png new file mode 100644 index 0000000..fabe9d9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/drawer_shadow.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_actionbar.png b/app/src/main/res/drawable-xhdpi/ic_actionbar.png new file mode 100644 index 0000000..67daf32 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_chevron_down_light.png b/app/src/main/res/drawable-xhdpi/ic_chevron_down_light.png new file mode 100644 index 0000000..a03c27d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_chevron_down_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_chevron_up_light.png b/app/src/main/res/drawable-xhdpi/ic_chevron_up_light.png new file mode 100644 index 0000000..0c737f3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_chevron_up_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notification.png b/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..2b8aa09 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_refresh_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_refresh_black_24dp.png new file mode 100644 index 0000000..cb664d2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_refresh_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_refresh_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_refresh_white_24dp.png new file mode 100644 index 0000000..a4a8de4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_refresh_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_search_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_search_black_24dp.png new file mode 100644 index 0000000..48a067a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_search_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..fe7e4e6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_share_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_share_black_24dp.png new file mode 100644 index 0000000..87790f1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_share_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_share_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_share_white_24dp.png new file mode 100644 index 0000000..9d2a8a1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_share_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/splash.png b/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..2cc195f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/app/src/main/res/drawable-xxhdpi/drawer_shadow.9.png b/app/src/main/res/drawable-xxhdpi/drawer_shadow.9.png new file mode 100644 index 0000000..b91e9d7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/drawer_shadow.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_actionbar.png b/app/src/main/res/drawable-xxhdpi/ic_actionbar.png new file mode 100644 index 0000000..08eb08a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_chevron_down_light.png b/app/src/main/res/drawable-xxhdpi/ic_chevron_down_light.png new file mode 100644 index 0000000..d12e68d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_chevron_down_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_chevron_up_light.png b/app/src/main/res/drawable-xxhdpi/ic_chevron_up_light.png new file mode 100644 index 0000000..d83c937 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_chevron_up_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification.png b/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000..706719e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_refresh_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_refresh_black_24dp.png new file mode 100644 index 0000000..cd2d35b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_refresh_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_refresh_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_refresh_white_24dp.png new file mode 100644 index 0000000..40b3dc6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_refresh_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_search_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_search_black_24dp.png new file mode 100644 index 0000000..a77abd2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..7289178 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_share_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_share_black_24dp.png new file mode 100644 index 0000000..0249b37 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_share_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_share_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_share_white_24dp.png new file mode 100644 index 0000000..fb9c996 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_share_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/splash.png b/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..9340324 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_actionbar.png b/app/src/main/res/drawable-xxxhdpi/ic_actionbar.png new file mode 100644 index 0000000..1066117 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_actionbar.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000..e5e9d6b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_refresh_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_refresh_black_24dp.png new file mode 100644 index 0000000..1de5043 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_refresh_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_refresh_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_refresh_white_24dp.png new file mode 100644 index 0000000..942eec9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_refresh_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_search_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_search_black_24dp.png new file mode 100644 index 0000000..b7e23ae Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_search_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..7fc7960 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_share_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_share_black_24dp.png new file mode 100644 index 0000000..c8ab5e0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_share_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_share_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_share_white_24dp.png new file mode 100644 index 0000000..b786dbb Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_share_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/splash.png b/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..54fdc80 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/app/src/main/res/drawable/bg_nav_icon.xml b/app/src/main/res/drawable/bg_nav_icon.xml new file mode 100644 index 0000000..154fdd6 --- /dev/null +++ b/app/src/main/res/drawable/bg_nav_icon.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml new file mode 100644 index 0000000..c9d2255 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml new file mode 100644 index 0000000..8efc968 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_go_back.xml b/app/src/main/res/drawable/ic_go_back.xml new file mode 100644 index 0000000..c276722 --- /dev/null +++ b/app/src/main/res/drawable/ic_go_back.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_go_forward.xml b/app/src/main/res/drawable/ic_go_forward.xml new file mode 100644 index 0000000..307409f --- /dev/null +++ b/app/src/main/res/drawable/ic_go_forward.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_stat_onesignal_default.xml b/app/src/main/res/drawable/ic_stat_onesignal_default.xml new file mode 100644 index 0000000..dbd375d --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_onesignal_default.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/shape_rounded.xml b/app/src/main/res/drawable/shape_rounded.xml new file mode 100644 index 0000000..74c2ddb --- /dev/null +++ b/app/src/main/res/drawable/shape_rounded.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/actionbar_title.xml b/app/src/main/res/layout/actionbar_title.xml new file mode 100644 index 0000000..8f4571e --- /dev/null +++ b/app/src/main/res/layout/actionbar_title.xml @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_gonative.xml b/app/src/main/res/layout/activity_gonative.xml new file mode 100644 index 0000000..eaa69a2 --- /dev/null +++ b/app/src/main/res/layout/activity_gonative.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/button_menu.xml b/app/src/main/res/layout/button_menu.xml new file mode 100644 index 0000000..78a671b --- /dev/null +++ b/app/src/main/res/layout/button_menu.xml @@ -0,0 +1,13 @@ + + +