Repository: gonativeio/gonative-android Branch: gonative-public Commit: 250d90c9f096 Files: 122 Total size: 554.2 KB Directory structure: gitextract_20zoy71i/ ├── .github/ │ └── workflows/ │ └── firebase-testing.yml ├── .gitignore ├── AppIcon ├── CHANGELOG.md ├── HeaderImage ├── NotificationIcon ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-project.txt │ └── src/ │ ├── androidTest/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ ├── HelperClass.java │ │ │ └── appConfig.json │ │ └── java/ │ │ └── com/ │ │ └── gonative/ │ │ └── testFiles/ │ │ ├── FirstTestClass.java │ │ └── TestMethods.java │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ ├── BlobDownloader.js │ │ │ ├── GoNativeJSBridgeLibrary.js │ │ │ ├── appConfig.json │ │ │ ├── custom-icons.json │ │ │ └── offline.html │ │ ├── java/ │ │ │ └── io/ │ │ │ └── gonative/ │ │ │ └── android/ │ │ │ ├── ActionManager.java │ │ │ ├── AppLinksActivity.java │ │ │ ├── AudioUtils.java │ │ │ ├── ConfigPreferences.java │ │ │ ├── ConfigUpdater.java │ │ │ ├── CustomHeaders.java │ │ │ ├── DownloadService.java │ │ │ ├── FileDownloader.java │ │ │ ├── FileUploadIntentsCreator.kt │ │ │ ├── FileWriterSharer.java │ │ │ ├── GoNativeApplication.java │ │ │ ├── GoNativeWindowManager.java │ │ │ ├── HtmlIntercept.java │ │ │ ├── IOUtils.java │ │ │ ├── Installation.java │ │ │ ├── JsCustomCodeExecutor.java │ │ │ ├── JsResultBridge.java │ │ │ ├── JsonMenuAdapter.java │ │ │ ├── KeyboardManager.kt │ │ │ ├── LoginManager.java │ │ │ ├── MainActivity.java │ │ │ ├── MySwipeRefreshLayout.java │ │ │ ├── ProfilePicker.java │ │ │ ├── RegistrationManager.java │ │ │ ├── SegmentedController.java │ │ │ ├── ShakeDialogFragment.java │ │ │ ├── SplashActivity.java │ │ │ ├── TabManager.java │ │ │ ├── UrlInspector.java │ │ │ ├── UrlNavigation.java │ │ │ ├── WebViewPool.java │ │ │ ├── WebViewPoolDisownPolicy.java │ │ │ ├── files/ │ │ │ │ └── CapturedImageSaver.kt │ │ │ └── widget/ │ │ │ ├── CircleImageView.java │ │ │ ├── GoNativeDrawerLayout.java │ │ │ ├── GoNativeSwipeRefreshLayout.java │ │ │ ├── HandleView.kt │ │ │ ├── SwipeHistoryNavigationLayout.kt │ │ │ └── WebViewContainerView.java │ │ └── res/ │ │ ├── anim/ │ │ │ └── fast_fade_out.xml │ │ ├── drawable/ │ │ │ ├── bg_nav_icon.xml │ │ │ ├── ic_baseline_arrow_back_24.xml │ │ │ ├── ic_baseline_arrow_forward_24.xml │ │ │ ├── ic_go_back.xml │ │ │ ├── ic_go_forward.xml │ │ │ ├── ic_stat_onesignal_default.xml │ │ │ └── shape_rounded.xml │ │ ├── layout/ │ │ │ ├── actionbar_title.xml │ │ │ ├── activity_gonative.xml │ │ │ ├── activity_subscriptions.xml │ │ │ ├── button_menu.xml │ │ │ ├── empty.xml │ │ │ ├── menu_child_icon.xml │ │ │ ├── menu_child_noicon.xml │ │ │ ├── menu_group_icon.xml │ │ │ ├── menu_group_noicon.xml │ │ │ ├── profile_picker_dropdown.xml │ │ │ ├── splash_screen.xml │ │ │ ├── tab.xml │ │ │ └── view_handle.xml │ │ ├── menu/ │ │ │ └── topmenu.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values/ │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── integers.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── values-ko/ │ │ │ └── strings.xml │ │ ├── values-large/ │ │ │ └── styles.xml │ │ ├── values-night/ │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ ├── values-night-v29/ │ │ │ └── styles.xml │ │ ├── values-sw600dp/ │ │ │ ├── attr.xml │ │ │ └── dimens.xml │ │ ├── values-sw720dp-land/ │ │ │ └── dimens.xml │ │ ├── values-v21/ │ │ │ └── styles.xml │ │ ├── values-v29/ │ │ │ └── styles.xml │ │ └── xml/ │ │ ├── filepaths.xml │ │ └── network_security_config.xml │ └── normal/ │ └── java/ │ └── io/ │ └── gonative/ │ └── android/ │ ├── GoNativeWebChromeClient.java │ ├── GoNativeWebviewClient.java │ ├── LeanWebView.java │ ├── PoolWebViewClient.java │ ├── WebViewSetup.java │ └── WebkitCookieManagerProxy.java ├── build.gradle ├── generate-app-icons.sh ├── generate-header-images.sh ├── generate-plugin-icons.sh ├── generate-theme.js ├── generate-tinted-icons.sh ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── plugins.gradle └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/firebase-testing.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ *~ .idea/ .gradle/ *.iml local.properties app/build/ build/ captures/ .DS_Store plugins/ ================================================ FILE: CHANGELOG.md ================================================ # 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. ================================================ FILE: README.md ================================================ # Archive Notice This repository is archived and is no longer being maintained. You can now build an app using our online App Studio at https://median.co/app and download custom iOS source code for free. gonative-android ================ This is the native Android code previously used by [GoNative](https://median.co). It allows the creation of apps from existing mobile-optimized websites. How to use ------------ Import into Android Studio. Edit appConfig.json as appropriate. ================================================ FILE: app/.gitignore ================================================ build/ ================================================ FILE: app/build.gradle ================================================ 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 33 minSdkVersion 21 targetSdkVersion 33 applicationId "io.gonative.android" versionCode 1 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_1_8 targetCompatibility JavaVersion.VERSION_1_8 } 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 'io.gonative.android' testNamespace '${applicationId}.test' } dependencies { /**** dependencies used by all apps ****/ implementation "androidx.core:core-ktx:1.10.1" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'com.aurelhubert:ahbottomnavigation:2.3.4' implementation 'com.squareup:seismic:1.0.2' implementation 'androidx.webkit:webkit:1.7.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.5.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.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) ================================================ FILE: app/proguard-project.txt ================================================ # 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 io.gonative.android.ProfilePicker$ProfileJsBridge { ; } -keepclassmembers class io.gonative.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.** { *; } ================================================ FILE: app/src/androidTest/AndroidManifest.xml ================================================ ================================================ FILE: app/src/androidTest/assets/HelperClass.java ================================================ package io.gonative.android; public class HelperClass { public volatile static int newLoad = 0; } ================================================ FILE: app/src/androidTest/assets/appConfig.json ================================================ { "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" }] } } } ================================================ FILE: app/src/androidTest/java/com/gonative/testFiles/FirstTestClass.java ================================================ package com.gonative.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.gonative.android.MainActivity; import io.gonative.android.R; import io.gonative.gonative_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(); } } } ================================================ FILE: app/src/androidTest/java/com/gonative/testFiles/TestMethods.java ================================================ package com.gonative.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.gonative.android.MainActivity; import io.gonative.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(); } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/BlobDownloader.js ================================================ // 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(); } } ================================================ FILE: app/src/main/assets/GoNativeJSBridgeLibrary.js ================================================ // 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 && (params.callback || params.callbackFunction)){ // execute command with provided callback function addCommand(command, params, persistCallback); } else { // create a temporary function and return a promise that executes command var tempFunctionName = '_gonative_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 = '_gonative_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.gonativeCommand = command; commandObject.data = params; data = JSON.stringify(commandObject); } else data = command; JSBridge.postMessage(data); } /////////////////////////////// //// General Commands //// /////////////////////////////// var gonative = {}; // to be modified as required gonative.nativebridge = { custom: function (params){ addCommand("gonative://nativebridge/custom", params); }, multi: function (params){ addCommand("gonative://nativebridge/multi", params); } }; gonative.registration = { send: function(customData){ var params = {customData: customData}; addCommand("gonative://registration/send", params); } }; gonative.sidebar = { setItems: function (params){ addCommand("gonative://sidebar/setItems", params); }, getItems: function (params){ return addCommandCallback("gonative://sidebar/getItems", params); } }; gonative.tabNavigation = { selectTab: function (tabIndex){ addCommand("gonative://tabs/select/" + tabIndex); }, deselect: function (){ addCommand("gonative://tabs/deselect"); }, setTabs: function (tabsObject){ var params = {tabs: tabsObject}; addCommand("gonative://tabs/setTabs", params); } }; gonative.share = { sharePage: function (params){ addCommand("gonative://share/sharePage", params); }, downloadFile: function (params){ addCommand("gonative://share/downloadFile", params); }, downloadImage: function(params){ addCommand("gonative://share/downloadImage", params); } }; gonative.open = { appSettings: function (){ addCommand("gonative://open/app-settings"); } }; gonative.webview = { clearCache: function(){ addCommand("gonative://webview/clearCache"); }, clearCookies: function(){ addCommand("gonative://webview/clearCookies"); }, reload: function (){ addCommand("gonative://webview/reload"); } }; gonative.config = { set: function(params){ addCommand("gonative://config/set", params); } }; gonative.navigationTitles = { set: function (parameters){ var params = { persist: parameters.persist, data: parameters }; addCommand("gonative://navigationTitles/set", params); }, setCurrent: function (params){ addCommand("gonative://navigationTitles/setCurrent", params); }, revert: function(){ addCommand("gonative://navigationTitles/set?persist=true"); } }; gonative.navigationLevels = { set: function (parameters){ var params = { persist: parameters.persist, data: parameters }; addCommand("gonative://navigationLevels/set", params); }, setCurrent: function(params){ addCommand("gonative://navigationLevels/set", params); }, revert: function(){ addCommand("gonative://navigationLevels/set?persist=true"); } }; gonative.statusbar = { set: function (params){ addCommand("gonative://statusbar/set", params); } }; gonative.screen = { setBrightness: function(data){ var params = data; if(typeof params === 'number'){ params = {brightness: data}; } addCommand("gonative://screen/setBrightness", params); }, setMode: function(params) { if (params.mode) { addCommand("gonative://screen/setMode", params); } } }; gonative.navigationMaxWindows = { set: function (maxWindows, autoClose){ var params = { data: maxWindows, autoClose: autoClose, persist: true }; addCommand("gonative://navigationMaxWindows/set", params); }, setCurrent: function(maxWindows, autoClose){ var params = {data: maxWindows, autoClose: autoClose}; addCommand("gonative://navigationMaxWindows/set", params); } } gonative.window = { open: function (urlString) { var params = {url: urlString}; addCommand("gonative://window/open", params); }, close: function () { addCommand("gonative://window/close"); } } gonative.connectivity = { get: function (params){ return addCommandCallback("gonative://connectivity/get", params); }, subscribe: function (params){ return addCommandCallback("gonative://connectivity/subscribe", params, true); }, unsubscribe: function (){ addCommand("gonative://connectivity/unsubscribe"); } }; gonative.run = { deviceInfo: function(){ addCommand("gonative://run/gonative_device_info"); } }; gonative.deviceInfo = function(params){ return addCommandCallback("gonative://run/gonative_device_info", params, true); }; gonative.internalExternal = { set: function(params){ addCommand("gonative://internalExternal/set", params); } }; gonative.clipboard = { set: function(params){ addCommand("gonative://clipboard/set", params); }, get: function(params){ return addCommandCallback("gonative://clipboard/get", params); } }; gonative.keyboard = { info: function(params){ return addCommandCallback("gonative://keyboard/info", params); }, listen: function(callback){ var params = {callback}; addCommand("gonative://keyboard/listen", params); } }; ////////////////////////////////////// //// Webpage Helper Functions //// ////////////////////////////////////// function gonative_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){ gonative.statusbar.set({'style': 'dark', 'color': hex}); } else{ gonative.statusbar.set({'style': 'light', 'color': hex}); } } /////////////////////////////// //// Android Exclusive //// /////////////////////////////// gonative.android = {}; gonative.android.geoLocation = { promptLocationServices: function(){ addCommand("gonative://geoLocation/promptLocationServices"); }, isLocationServicesEnabled: function(params) { return addCommandCallback("gonative://geoLocation/isLocationServicesEnabled", params); } }; gonative.android.screen = { fullscreen: function(){ addCommand("gonative://screen/fullscreen"); }, normal: function(){ addCommand("gonative://screen/normal"); }, keepScreenOn: function(){ addCommand("gonative://screen/keepScreenOn"); }, keepScreenNormal: function(){ addCommand("gonative://screen/keepScreenNormal"); } }; gonative.android.audio = { requestFocus: function(enabled){ var params = {enabled: enabled}; addCommand("gonative://audio/requestFocus", params); } }; gonative.android.swipeGestures = { enable: function() { addCommand("gonative://swipeGestures/enable"); }, disable: function() { addCommand("gonative://swipeGestures/disable"); } } ================================================ FILE: app/src/main/assets/appConfig.json ================================================ { "general": { "appName": "GoNative.io", "initialUrl": "https://gonative.io/", "userAgentRegexes": [], "replaceStrings": [], "nativeBridgeUrls": [], "languages": [], "userAgentAdd": "gonative", "enableWindowOpen": true, "forceScreenOrientation": null }, "navigation": { "tabNavigation": { "tabSelectionConfig": [ { "id": "1", "regex": "https://gonative.io/" } ], "tabMenus": [ { "items": [ { "subLinks": [], "icon": "custom custom-gonative-icon", "label": "Home", "url": "https://gonative.io" }, { "subLinks": [], "icon": "fas fa-globe", "label": "Tab 2", "url": "javascript:alert('You selected tab 2. These tabs are only shown on the home page')" }, { "subLinks": [], "icon": "fas fa-laptop-code", "label": "GoNative Demo", "url": "https://gonative.io/demo" } ], "id": "1" } ], "active": true }, "sidebarNavigation": { "menuSelectionConfig": { "testURL": null, "redirectLocations": [ { "regex": null, "menuName": "default", "loggedIn": false }, { "regex": ".*", "menuName": "default", "loggedIn": true } ] }, "menus": [ { "items": [ { "subLinks": [], "label": "Sample Home", "url": "https://gonative.io", "icon": "fas fa-home" }, { "subLinks": [], "label": "Sample About", "url": "https://gonative.io/about", "icon": "fas fa-user" } ], "name": "default", "active": true }, { "items": null, "name": "loggedIn", "active": false } ] }, "regexInternalExternal": { "rules": [ { "regex": "^(?!https?://).*", "internal": false }, { "regex": "https?://([-\\w]+\\.)*facebook\\.com/login.php.*", "internal": true }, { "regex": "https?://([-\\w]+\\.)*facebook\\.com/dialog.*", "internal": true }, { "regex": "https?://([-\\w]+\\.)*facebook\\.com/v1\\.0/.*", "internal": true }, { "regex": "https?://([-\\w]+\\.)*facebook\\.com/oauth.*", "internal": true }, { "regex": "https?://([-\\w]+\\.)*facebook\\.com/v2\\.0/.*", "internal": true }, { "regex": "https?://([-\\w]+\\.)*facebook\\.com/checkpoint.*", "internal": true }, { "regex": "https?://([-\\w]+\\.)*facebook\\.com.*", "internal": true }, { "regex": "https?://([-\\w]+\\.)*plus\\.google\\.com/share.*", "internal": false }, { "regex": "https?://([-\\w]+\\.)*twitter\\.com/.*", "internal": false }, { "regex": "https?://([-\\w]+\\.)*instagram\\.com/.*", "internal": false }, { "regex": "https?://maps\\.google\\.com.*", "internal": false }, { "regex": "https?://([-\\w]+\\.)*linkedin\\.com/.*", "internal": false }, { "regex": "https?://([-\\w]+\\.)*google\\.com/maps/search/.*", "internal": false }, { "regex": ".*", "internal": true } ], "active": false }, "androidPullToRefresh": false, "iosPullToRefresh": true, "navigationTitles": { "titles": [ {} ], "active": false }, "toolbarNavigation": { "items": [ { "system": "back", "title": "Back" }, { "system": "forward", "title": "Forward" }, { "system": "refresh" } ] }, "androidShowRefreshButton": false, "deepLinkDomains": { "domains": [], "enableAndroidApplinks": false }, "navigationLevels": { "levels": [ {} ] } }, "styling": { "transitionInteractiveDelayMax": 0.2, "menuAnimationDuration": 0.15, "androidShowSplash": true, "disableAnimations": false, "hideWebviewAlpha": 0.5, "showActionBar": true, "showNavigationBar": true, "iosSidebarFont": "Default", "androidHideTitleInActionBar": true, "navigationTitleImage": true, "iosTheme": "default", "androidTheme": "auto", "androidSidebarBackgroundColor": "#FFFFFF", "androidSidebarForegroundColor": "#1E496E", "androidActionBarBackgroundColor": "#FFFFFF", "androidActionBarForegroundColor": "#1E496E", "androidAccentColor": "#1E496E", "androidSidebarSeparatorColor": "#CCCCCC", "androidSidebarHighlightColor": "#1E496E", "androidShowLogoInSideBar": true, "androidShowAppNameInSideBar": true, "androidPullToRefreshColor": "#1E496E", "androidTabBarBackgroundColor": "#FFFFFF", "androidTabBarTextColor": "#949494", "androidTabBarIndicatorColor": "#1E496E", "androidStatusBarBackgroundColor": "#5C5C5C", "iosTintColor": "#1E496E", "iosTitleColor": "#1E496E", "iosSidebarTextColor": "#1E496E", "androidBackgroundColor": "#FFFFFF", "androidSwipeNavigationBackgroundColor": "#FFFFFF", "androidSwipeNavigationActiveColor": "#000000", "androidSwipeNavigationInactiveColor": "#666666", "androidActionBarBackgroundColorDark": "#333333", "androidStatusBarBackgroundColorDark": "#333333", "androidActionBarForegroundColorDark": "#FFFFFF", "androidAccentColorDark": "#666666", "androidBackgroundColorDark": "#333333", "androidSidebarForegroundColorDark": "#FFFFFF", "androidSidebarBackgroundColorDark": "#333333", "androidSidebarSeparatorColorDark": "#666666", "androidSidebarHighlightColorDark": "#FFFFFF", "androidPullToRefreshColorDark": "#FFFFFF", "androidTabBarTextColorDark": "#FFFFFF", "androidTabBarBackgroundColorDark": "#333333", "androidTabBarIndicatorColorDark": "#666666", "androidSwipeNavigationBackgroundColorDark": "#333333", "androidSwipeNavigationActiveColorDark": "#FFFFFF", "androidSwipeNavigationInactiveColorDark": "#666666" }, "permissions": { "usesGeolocation": false, "androidDownloadToPublicStorage": true, "enableWebRTC": false }, "performance": { "webviewPools": [ { "urls": [ { "disown": "reload" } ] } ] }, "services": { "facebook": { "pluginName": "" } } } ================================================ FILE: app/src/main/assets/custom-icons.json ================================================ { "gonative-icon": 59392 } ================================================ FILE: app/src/main/assets/offline.html ================================================ Device Offline

No internet connection
Check your connection and try again

================================================ FILE: app/src/main/java/io/gonative/android/ActionManager.java ================================================ package io.gonative.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 io.gonative.gonative_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); // fix title offset when side menu hamburger icon is not yet available if (appConfig.showNavigationMenu && isRoot) { menuContainer.getLayoutParams().width = 140; } } 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); } // Remove title offset once sidebar hamburger menu setup is complete public void cleanSidebarMenuTitleOffset() { if (menuContainer == null) return; menuContainer.getLayoutParams().width = 0; } 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; } } } ================================================ FILE: app/src/main/java/io/gonative/android/AppLinksActivity.java ================================================ package io.gonative.android; import android.content.Intent; import android.os.Bundle; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; public class AppLinksActivity extends AppCompatActivity { @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); } startActivity(intent); finish(); } } ================================================ FILE: app/src/main/java/io/gonative/android/AudioUtils.java ================================================ package io.gonative.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; } } } } ================================================ FILE: app/src/main/java/io/gonative/android/ConfigPreferences.java ================================================ package io.gonative.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(); } } } ================================================ FILE: app/src/main/java/io/gonative/android/ConfigUpdater.java ================================================ package io.gonative.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_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; } } } ================================================ FILE: app/src/main/java/io/gonative/android/CustomHeaders.java ================================================ package io.gonative.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 io.gonative.gonative_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; } } ================================================ FILE: app/src/main/java/io/gonative/android/DownloadService.java ================================================ package io.gonative.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_core.GNLog; import io.gonative.gonative_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; 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; } String contentDisposition = connection.getHeaderField("Content-Disposition"); 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, contentDisposition, 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.PRIVATE_INTERNAL && !TextUtils.isEmpty(contentDisposition) && contentDisposition.startsWith("inline")) this.openOnFinish = true; 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) { outputStream = (FileOutputStream) contentResolver.openOutputStream(downloadUri); } } else { if (saveToGallery) { outputFile = FileDownloader.createOutputFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), filename, extension); } else { outputFile = FileDownloader.createOutputFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), filename, extension); } outputStream = new FileOutputStream(outputFile); } } else { this.openOnFinish = true; outputFile = FileDownloader.createOutputFile(getFilesDir(), filename, extension); 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, filename + "." + extension); } }).start(); } public void cancelDownload() { isDownloading = false; Toast.makeText(DownloadService.this, getString(R.string.download_canceled) + " " + filename, Toast.LENGTH_SHORT).show(); } } } ================================================ FILE: app/src/main/java/io/gonative/android/FileDownloader.java ================================================ package io.gonative.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.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_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); if (Objects.equals(environment, Environment.DIRECTORY_PICTURES)) { contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES); return contentResolver.insert(MediaStore.Images.Media.getContentUri("external"), contentValues); } else if (Objects.equals(environment, Environment.DIRECTORY_DOWNLOADS)) { contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); return contentResolver.insert(MediaStore.Files.getContentUri("external"), contentValues); } return null; } 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; } } } ================================================ FILE: app/src/main/java/io/gonative/android/FileUploadIntentsCreator.kt ================================================ package io.gonative.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.Environment import android.os.Parcelable import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import io.gonative.gonative_core.AppConfig import io.gonative.gonative_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("*/*") } } fun imagesAllowed(): Boolean { if (!Utils.isPermissionGranted(context as Activity, android.Manifest.permission.CAMERA)) return false return mimeTypes.contains("*/*") || mimeTypes.any { it.contains("image/") } } fun videosAllowed(): Boolean { if (!Utils.isPermissionGranted(context as Activity, android.Manifest.permission.CAMERA)) return false 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) } } } ================================================ FILE: app/src/main/java/io/gonative/android/FileWriterSharer.java ================================================ package io.gonative.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_core.GNLog; import io.gonative.gonative_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; } } ================================================ FILE: app/src/main/java/io/gonative/android/GoNativeApplication.java ================================================ package io.gonative.android; import android.os.Message; import android.webkit.ValueCallback; import android.widget.Toast; import androidx.appcompat.app.AppCompatDelegate; import androidx.multidex.MultiDexApplication; import java.util.List; import java.util.Map; import io.gonative.gonative_core.AppConfig; import io.gonative.gonative_core.Bridge; import io.gonative.gonative_core.BridgeModule; import io.gonative.gonative_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 ValueCallback webviewValueCallback; 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; } }; @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(); } // Needed for Crosswalk @SuppressWarnings("unused") public ValueCallback getWebviewValueCallback() { return webviewValueCallback; } public void setWebviewValueCallback(ValueCallback webviewValueCallback) { this.webviewValueCallback = webviewValueCallback; } public GoNativeWindowManager getWindowManager() { return goNativeWindowManager; } } ================================================ FILE: app/src/main/java/io/gonative/android/GoNativeWindowManager.java ================================================ package io.gonative.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(); } } ================================================ FILE: app/src/main/java/io/gonative/android/HtmlIntercept.java ================================================ package io.gonative.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_core.GNLog; import io.gonative.gonative_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.customCSS != null || appConfig.androidCustomCSS != null) { builder.append(""); } if (appConfig.customJS != null || appConfig.androidCustomJS != null) { builder.append(""); } 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; } } ================================================ FILE: app/src/main/java/io/gonative/android/IOUtils.java ================================================ package io.gonative.android; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import io.gonative.gonative_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); } } } ================================================ FILE: app/src/main/java/io/gonative/android/Installation.java ================================================ package io.gonative.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_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; } } ================================================ FILE: app/src/main/java/io/gonative/android/JsCustomCodeExecutor.java ================================================ package io.gonative.android; import org.json.JSONException; import org.json.JSONObject; import java.util.Map; import io.gonative.gonative_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; } } } ================================================ FILE: app/src/main/java/io/gonative/android/JsResultBridge.java ================================================ package io.gonative.android; public class JsResultBridge { public static String jsResult = ""; } ================================================ FILE: app/src/main/java/io/gonative/android/JsonMenuAdapter.java ================================================ package io.gonative.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_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; } } ================================================ FILE: app/src/main/java/io/gonative/android/KeyboardManager.kt ================================================ package io.gonative.android import android.graphics.Rect import android.text.TextUtils import android.view.ViewGroup import io.gonative.gonative_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("visible", isKeyboardVisible) keyboardWindowSize.put("width", keyboardWidth) keyboardWindowSize.put("height", keyboardHeight) val visibleWindowSize = JSONObject() visibleWindowSize.put("width", screenWidth) visibleWindowSize.put("height", screenHeight) val data = JSONObject() data.put("keyboardWindowSize", keyboardWindowSize) data.put("visibleWindowSize", visibleWindowSize) return data } } ================================================ FILE: app/src/main/java/io/gonative/android/LoginManager.java ================================================ package io.gonative.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_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; } } } } } ================================================ FILE: app/src/main/java/io/gonative/android/MainActivity.java ================================================ package io.gonative.android; import android.Manifest; import android.annotation.SuppressLint; 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.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.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.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.ProgressBar; 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.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.DialogFragment; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.webkit.WebSettingsCompat; import androidx.webkit.WebViewFeature; import com.aurelhubert.ahbottomnavigation.AHBottomNavigation; import com.squareup.seismic.ShakeDetector; import org.json.JSONException; import org.json.JSONObject; import java.io.File; 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.Map; import java.util.Observable; import java.util.Observer; import java.util.Stack; import java.util.UUID; import java.util.regex.Pattern; import io.gonative.android.files.CapturedImageSaver; import io.gonative.gonative_core.AppConfig; import io.gonative.android.widget.GoNativeDrawerLayout; import io.gonative.android.widget.GoNativeSwipeRefreshLayout; import io.gonative.android.widget.SwipeHistoryNavigationLayout; import io.gonative.android.widget.WebViewContainerView; import io.gonative.gonative_core.GNLog; import io.gonative.gonative_core.GoNativeActivity; import io.gonative.gonative_core.GoNativeWebviewInterface; import io.gonative.gonative_core.LeanUtils; 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 = "gonative_app_resumed"; 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; private View webviewOverlay; boolean isPoolWebview = false; private Stack backHistory = new Stack<>(); 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 ProgressBar mProgress; private MySwipeRefreshLayout swipeRefreshLayout; private SwipeHistoryNavigationLayout swipeNavLayout; private RelativeLayout fullScreenLayout; private JsonMenuAdapter menuAdapter = null; private ActionBarDrawerToggle mDrawerToggle; private AHBottomNavigation bottomNavigationView; private ConnectivityManager cm = null; private ProfilePicker profilePicker = null; private TabManager tabManager; private ActionManager actionManager; private boolean isRoot; private float hideWebviewAlpha = 0.0f; private boolean isFirstHideWebview = true; private boolean webviewIsHidden = false; private Handler handler = new Handler(); private Menu mOptionsMenu; private String activityId; private Runnable statusChecker = new Runnable() { @Override public void run() { runOnUiThread(new Runnable() { @Override public void run() { checkReadyStatus(); } }); handler.postDelayed(statusChecker, 100); // 0.1 sec } }; private ShakeDetector shakeDetector = new ShakeDetector(this); private FileDownloader fileDownloader; private FileWriterSharer fileWriterSharer; private boolean startedLoading = false; // document readystate checker private LoginManager loginManager; private RegistrationManager registrationManager; private ConnectivityChangeReceiver connectivityReceiver; private KeyboardManager keyboardManager; private BroadcastReceiver navigationTitlesChangedReceiver; private BroadcastReceiver navigationLevelsChangedReceiver; private BroadcastReceiver webviewLimitReachedReceiver; 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 String deviceInfoCallback = ""; private boolean flagThemeConfigurationChange = false; @Override protected void onCreate(Bundle savedInstanceState) { final AppConfig appConfig = AppConfig.getInstance(this); GoNativeApplication application = (GoNativeApplication)getApplication(); GoNativeWindowManager windowManager = application.getWindowManager(); 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("GNDebug", "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(); this.isRoot = getIntent().getBooleanExtra("isRoot", true); 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); } application.mBridge.onActivityCreate(this, isRoot); 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); 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; if (savedInstanceState != null) { Bundle webViewStateBundle = savedInstanceState.getBundle(SAVED_STATE_WEBVIEW_STATE); if (webViewStateBundle != null) { // Restore page and history mWebview.restoreStateFromBundle(webViewStateBundle); isWebViewStateRestored = true; } // Restore scroll state 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.bottomNavigationView = findViewById(R.id.bottom_navigation); this.tabManager = new TabManager(this, bottomNavigationView); hideTabs(); 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); updateStatusBarOverlay(appConfig.enableOverlayInStatusBar); updateStatusBarStyle(appConfig.statusBarStyle); this.keyboardManager = new KeyboardManager(this, this.findViewById(android.R.id.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)); application.mBridge.onSendInstallationInfo(this, Installation.getInfo(this), mWebview.getUrl()); validateGoogleService(); requestPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { runGonativeDeviceInfo(deviceInfoCallback, false); }); } 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.mBridge.onActivityResume(this); this.mWebview.onResume(); if (isActivityPaused) { this.isActivityPaused = false; runJavascript(LeanUtils.createJsForCallback(ON_RESUME_CALLBACK, 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.getInstance(this).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()); } protected void onSaveInstanceState (Bundle outState) { // Saves current WebView's history and URL or loaded page state Bundle webViewOutState = new Bundle(); mWebview.saveStateToBundle(webViewOutState); outState.putBundle(SAVED_STATE_WEBVIEW_STATE, webViewOutState); // Save other WebView data 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 (optionalUrl == null || optionalUrl.isEmpty()) { 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 (shareUrl == null || shareUrl.isEmpty()) return; Intent share = new Intent(Intent.ACTION_SEND); share.setType("text/plain"); share.putExtra(Intent.EXTRA_TEXT, shareUrl); if (optionalText != null) { share.putExtra(Intent.EXTRA_SUBJECT, optionalText); } 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("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)) { // hideWebview(); runJavascript(javascript); this.postLoadJavascriptForRefresh = javascript; // showWebview(); } 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(new Runnable() { @Override public void run() { 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); injectCSSviaJavascript(); injectJSviaJavascript(); } @Override public void showWebview() { this.isFirstHideWebview = false; startedLoading = false; stopCheckingReadyStatus(); if (!webviewIsHidden) { // don't animate if already visible mProgress.setVisibility(View.INVISIBLE); return; } injectCSSviaJavascript(); injectJSviaJavascript(); webviewIsHidden = false; webviewOverlay.animate().alpha(0.0f) .setDuration(300) .setStartDelay(150); mProgress.animate().alpha(0.0f) .setDuration(60); } private void injectCSSviaJavascript() { AppConfig appConfig = AppConfig.getInstance(this); if ((appConfig.customCSS == null || appConfig.customCSS.isEmpty()) && (appConfig.androidCustomCSS == null || appConfig.androidCustomCSS.isEmpty())) return; try { StringBuilder builder = new StringBuilder(); if(appConfig.customCSS != null) builder.append(appConfig.customCSS).append(" "); if(appConfig.androidCustomCSS != null) builder.append(appConfig.androidCustomCSS); String encoded = Base64.encodeToString(builder.toString().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)" + "})()"; runJavascript(js); } catch (Exception e) { GNLog.getInstance().logError(TAG, "Error injecting customCSS via javascript", e); } } private void injectJSviaJavascript() { AppConfig appConfig = AppConfig.getInstance(this); if ((appConfig.customJS == null || appConfig.customJS.isEmpty()) && (appConfig.androidCustomJS == null || appConfig.androidCustomJS.isEmpty())) return; try { StringBuilder builder = new StringBuilder(); if(appConfig.customJS != null) builder.append(appConfig.customJS).append(" "); if(appConfig.androidCustomJS != null) builder.append(appConfig.androidCustomJS); String encoded = Base64.encodeToString(builder.toString().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)" + "})()"; runJavascript(js); } catch (Exception e) { GNLog.getInstance().logError(TAG, "Error injecting customJS via javascript", e); } } 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); } } // 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); } if (this.actionManager != null) { this.actionManager.cleanSidebarMenuTitleOffset(); } 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); } private void checkReadyStatus() { this.mWebview.runJavascript("if (gonative_status_checker && typeof gonative_status_checker.onReadyState === 'function') gonative_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(); } } } public void showTabs() { this.bottomNavigationView.setVisibility(View.VISIBLE); } public void hideTabs() { this.bottomNavigationView.setVisibility(View.GONE); } @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(new Runnable() { @Override public void run() { 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.bottomNavigationView.setCurrentItem(AHBottomNavigation.CURRENT_ITEM_NONE); } 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) { View decor = getWindow().getDecorView(); if (isOverlayEnabled) { decor.setSystemUiVisibility(decor.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } else { decor.setSystemUiVisibility(decor.getSystemUiVisibility() & ~View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN & ~View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } } @Override public void updateStatusBarStyle(String statusBarStyle) { if (statusBarStyle != null && !statusBarStyle.isEmpty() && Build.VERSION.SDK_INT >= 23) { switch (statusBarStyle) { case "light": { // dark icons and text View decor = getWindow().getDecorView(); decor.setSystemUiVisibility(decor.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); break; } case "dark": { // light icons and text View decor = getWindow().getDecorView(); decor.setSystemUiVisibility(decor.getSystemUiVisibility() & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); break; } case "auto": int nightModeFlags = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) { View decor = getWindow().getDecorView(); decor.setSystemUiVisibility(decor.getSystemUiVisibility() & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } else if (nightModeFlags == Configuration.UI_MODE_NIGHT_NO) { View decor = getWindow().getDecorView(); decor.setSystemUiVisibility(decor.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } 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); } JSONObject jsonObject = new JSONObject(installationInfo); String js = LeanUtils.createJsForCallback(callback, jsonObject); this.runJavascript(js); } } @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) { if (TextUtils.isEmpty(url)) return; 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); } @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); } } ================================================ FILE: app/src/main/java/io/gonative/android/MySwipeRefreshLayout.java ================================================ package io.gonative.android; import android.content.Context; import android.util.AttributeSet; import io.gonative.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(); } } } ================================================ FILE: app/src/main/java/io/gonative/android/ProfilePicker.java ================================================ package io.gonative.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 io.gonative.gonative_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); } } } ================================================ FILE: app/src/main/java/io/gonative/android/RegistrationManager.java ================================================ package io.gonative.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 io.gonative.gonative_core.GNLog; import io.gonative.gonative_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; } } } ================================================ FILE: app/src/main/java/io/gonative/android/SegmentedController.java ================================================ package io.gonative.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 io.gonative.gonative_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 } } ================================================ FILE: app/src/main/java/io/gonative/android/ShakeDialogFragment.java ================================================ package io.gonative.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; 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"); } } } ================================================ FILE: app/src/main/java/io/gonative/android/SplashActivity.java ================================================ package io.gonative.android; import android.Manifest; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Color; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.view.View; import androidx.annotation.NonNull; import androidx.core.app.ActivityCompat; import androidx.appcompat.app.AppCompatActivity; import java.util.HashSet; import androidx.core.content.ContextCompat; import androidx.core.splashscreen.SplashScreen; import io.gonative.gonative_core.AppConfig; public class SplashActivity extends AppCompatActivity { private static final int REQUEST_STARTUP_PERMISSIONS = 100; @Override protected void onCreate(Bundle savedInstanceState) { SplashScreen.installSplashScreen(this); super.onCreate(savedInstanceState); AppConfig config = AppConfig.getInstance(this); getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); getWindow().setStatusBarColor(Color.TRANSPARENT); getWindow().setNavigationBarColor(Color.TRANSPARENT); setContentView(R.layout.splash_screen); HashSet permissionsToRequest = new HashSet<>(); if (LeanWebView.isCrosswalk()) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED){ permissionsToRequest.add(Manifest.permission.READ_CONTACTS); } if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED){ permissionsToRequest.add(Manifest.permission.WRITE_CONTACTS); } } if (permissionsToRequest.isEmpty()) { handleSplash(config); } else { ActivityCompat.requestPermissions(this, permissionsToRequest.toArray(new String[]{}), REQUEST_STARTUP_PERMISSIONS); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); startMainActivity(); } private void handleSplash(AppConfig config){ // Check app if unlicensed and show banner if (!config.isLicensed()) { findViewById(R.id.banner_text).setVisibility(View.VISIBLE); } Handler handler = new Handler(Looper.getMainLooper()); // handle splash double delay = 1.5; // in seconds double forceTime = config.showSplashForceTime; double maxTime = config.showSplashMaxTime; if (forceTime > 0) { delay = forceTime; } else if (maxTime > 0) { delay = maxTime; } handler.postDelayed(this::startMainActivity, (long)delay * 1000); } private void startMainActivity() { Intent intent = new Intent(this, MainActivity.class); // Make MainActivity think it was started from launcher intent.addCategory(Intent.CATEGORY_LAUNCHER); intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); startActivity(intent); finish(); } } ================================================ FILE: app/src/main/java/io/gonative/android/TabManager.java ================================================ package io.gonative.android; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.text.TextUtils; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.aurelhubert.ahbottomnavigation.AHBottomNavigation; import com.aurelhubert.ahbottomnavigation.AHBottomNavigationItem; 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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_core.GNLog; import io.gonative.gonative_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 AHBottomNavigation.OnTabSelectedListener { private static final String TAG = TabManager.class.getName(); private MainActivity mainActivity; private AHBottomNavigation bottomNavigationView; private String currentMenuId; private String currentUrl; private JSONArray tabs; private Map tabMenus; private final int maxTabs = 5; private int tabbar_icon_size; private int tabbar_icon_padding; private Map> tabRegexCache = new HashMap<>(); // regex for each tab to auto-select private boolean useJavascript; // do not use tabs from config AppConfig appConfig; private boolean performAction = true; @SuppressWarnings("unused") private TabManager(){ // disable instantiation without mainActivity } TabManager(MainActivity mainActivity, AHBottomNavigation bottomNavigationView) { this.mainActivity = mainActivity; tabbar_icon_size = this.mainActivity.getResources().getInteger(R.integer.tabbar_icon_size); tabbar_icon_padding = this.mainActivity.getResources().getInteger(R.integer.tabbar_icon_padding); this.bottomNavigationView = bottomNavigationView; this.bottomNavigationView.setOnTabSelectedListener(this); this.appConfig = AppConfig.getInstance(this.mainActivity); this.bottomNavigationView.setTitleState(AHBottomNavigation.TitleState.ALWAYS_SHOW); this.bottomNavigationView.setDefaultBackgroundColor(mainActivity.getResources().getColor(R.color.tabBarBackground)); this.bottomNavigationView.setAccentColor(mainActivity.getResources().getColor(R.color.tabBarIndicator)); this.bottomNavigationView.setInactiveColor(mainActivity.getResources().getColor(R.color.tabBarTextColor)); this.bottomNavigationView.setTitleTextSizeInSp(12, 12); 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) { hideTabs(); 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; hideTabs(); } else if (this.currentMenuId == null || !this.currentMenuId.equals(id)) { this.currentMenuId = id; JSONArray tabs = AppConfig.getInstance(this.mainActivity).tabMenus.get(id); setTabs(tabs); if(bottomNavigationView.getItemsCount() == 0) { hideTabs(); } else { showTabs(); } } } private void setTabs(JSONArray tabs) { this.tabs = tabs; int selectedNumber = -1; bottomNavigationView.removeAllItems(); 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."); } AHBottomNavigationItem navigationItem = new AHBottomNavigationItem(label, new Icon(mainActivity.getApplicationContext(), icon, tabbar_icon_size, mainActivity.getResources().getColor(R.color.tabBarTextColor)).getDrawable()); bottomNavigationView.addItem(navigationItem); if (item.optBoolean("selected")) { selectedNumber = i; } } if (selectedNumber > -1) { selectTabNumber(selectedNumber, true); } } private void showTabs() { this.mainActivity.runOnUiThread(new Runnable() { @Override public void run() { mainActivity.showTabs(); } }); } private void hideTabs() { this.mainActivity.runOnUiThread(new Runnable() { @Override public void run() { mainActivity.hideTabs(); } }); } // 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()) { bottomNavigationView.setCurrentItem(i, false); 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 (entryUrl == null) continue; if (entryJs == null) entryJs = ""; if (url.equals(entryUrl) && javascript.equals(entryJs)) { if (this.bottomNavigationView != null) { this.bottomNavigationView.setCurrentItem(i, false); 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) { if ((Boolean)enabled) { this.showTabs(); } else { this.hideTabs(); } } } public void selectTabNumber(int tabNumber, boolean performAction) { if (tabNumber < 0 || tabNumber >= bottomNavigationView.getItemsCount()) { return; } this.performAction = performAction; this.bottomNavigationView.setCurrentItem(tabNumber); } @Override public boolean onTabSelected(int position, boolean wasSelected) { if (this.tabs != null && position < this.tabs.length() && position != -1) { JSONObject entry = this.tabs.optJSONObject(position); 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; } private class TabMenu { Pattern urlRegex; JSONArray tabs; } } ================================================ FILE: app/src/main/java/io/gonative/android/UrlInspector.java ================================================ package io.gonative.android; import android.content.Context; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import io.gonative.gonative_core.AppConfig; import io.gonative.gonative_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; } } ================================================ FILE: app/src/main/java/io/gonative/android/UrlNavigation.java ================================================ package io.gonative.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.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.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.InputStream; import java.net.URISyntaxException; 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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_core.GNLog; import io.gonative.gonative_core.GoNativeWebviewInterface; import io.gonative.gonative_core.IOUtils; import io.gonative.gonative_core.LeanUtils; import io.gonative.gonative_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 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 = ""; UrlNavigation(MainActivity activity) { this.mainActivity = activity; this.htmlIntercept = new HtmlIntercept(activity); AppConfig appConfig = AppConfig.getInstance(mainActivity); // profile picker if (appConfig.profilePickerJS != null) { this.profilePickerExec = "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); String urlString = uri.toString(); // first check regexes ArrayList regexes = appConfig.regexInternalExternal; ArrayList isInternal = appConfig.regexIsInternal; if (regexes != null) { for (int i = 0; i < regexes.size(); i++) { Pattern regex = regexes.get(i); if (regex.matcher(urlString).matches()) { return isInternal.get(i); } } } 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 ("gonative".equals(uri.getScheme()) && currentWebviewUrl != null && !LeanUtils.checkNativeBridgeUrls(currentWebviewUrl, mainActivity)) { GNLog.getInstance().logError(TAG, "URL not authorized for native bridge: " + currentWebviewUrl); return true; } if ("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; Log.d(TAG, "processing dynamic link: " + uri); Intent intent = null; // launch browser try { if (uri.getScheme().equals("intent")) { intent = Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME); } 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); } 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(new Runnable() { @Override public void run() { 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(new Runnable() { @Override public void run() { 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(); } }); } 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); } // 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("gonative_device_info"); } injectJSBridgeLibrary(currentWebviewUrl); ((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("gonative_library_ready", null)); } catch (Exception e) { Log.d(TAG, "GoNative JSBridgeLibrary Injection Error:- " + e.getMessage()); } } 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; } 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 (creator.videosAllowed() || creator.imagesAllowed()) { 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(); 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(); } } ================================================ FILE: app/src/main/java/io/gonative/android/WebViewPool.java ================================================ package io.gonative.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 io.gonative.gonative_core.AppConfig; import io.gonative.gonative_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; } } ================================================ FILE: app/src/main/java/io/gonative/android/WebViewPoolDisownPolicy.java ================================================ package io.gonative.android; /** * Created by weiyin on 9/3/14. */ public enum WebViewPoolDisownPolicy { Always, Reload, Never; public static WebViewPoolDisownPolicy defaultPolicy = WebViewPoolDisownPolicy.Reload; } ================================================ FILE: app/src/main/java/io/gonative/android/files/CapturedImageSaver.kt ================================================ package io.gonative.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 } } ================================================ FILE: app/src/main/java/io/gonative/android/widget/CircleImageView.java ================================================ package io.gonative.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); } } } ================================================ FILE: app/src/main/java/io/gonative/android/widget/GoNativeDrawerLayout.java ================================================ package io.gonative.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; } } ================================================ FILE: app/src/main/java/io/gonative/android/widget/GoNativeSwipeRefreshLayout.java ================================================ package io.gonative.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 io.gonative.gonative_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); } } ================================================ FILE: app/src/main/java/io/gonative/android/widget/HandleView.kt ================================================ package io.gonative.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 io.gonative.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() } } ================================================ FILE: app/src/main/java/io/gonative/android/widget/SwipeHistoryNavigationLayout.kt ================================================ package io.gonative.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 io.gonative.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 } } ================================================ FILE: app/src/main/java/io/gonative/android/widget/WebViewContainerView.java ================================================ package io.gonative.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 io.gonative.android.GoNativeApplication; import io.gonative.android.LeanWebView; import io.gonative.android.MainActivity; import io.gonative.android.WebViewSetup; import io.gonative.gonative_core.AppConfig; import io.gonative.gonative_core.Bridge; import io.gonative.gonative_core.GoNativeWebviewInterface; 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("io.gonative.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("io.gonative.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; } } ================================================ FILE: app/src/main/res/anim/fast_fade_out.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_nav_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_arrow_back_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_go_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_go_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_stat_onesignal_default.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_rounded.xml ================================================ ================================================ FILE: app/src/main/res/layout/actionbar_title.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_gonative.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_subscriptions.xml ================================================ ================================================ FILE: app/src/main/res/layout/button_menu.xml ================================================