diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac31227..ec9ead7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,17 +11,17 @@ on: jobs: test-ios: name: "iOS Tests" - runs-on: macos-12 + runs-on: macos-14 env: - DEVELOPER_DIR: /Applications/Xcode_13.4.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 - name: Set up example app run: | @@ -35,14 +35,63 @@ jobs: -project OAuthPluginTests.xcodeproj \ -scheme OAuthPluginTests \ -testPlan UnitTests \ - -destination "platform=iOS Simulator,name=iPhone 13" + -destination "platform=iOS Simulator,name=iPhone 15" working-directory: ./tests/ios - name: Run iOS UI Tests run: | - xcodebuild test -quiet \ + xcodebuild test \ -project OAuthPluginTests.xcodeproj \ -scheme OAuthPluginTests \ -testPlan DeviceTests \ - -destination "platform=iOS Simulator,name=iPhone 13" + -destination "platform=iOS Simulator,name=iPhone 15" \ + -destination-timeout 300 working-directory: ./tests/ios + + + test-android: + name: "Android Tests" + runs-on: ubuntu-latest + + steps: + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Use Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Set up example app + run: | + npm i + npx cordova prepare android + working-directory: ./example + + - name: Set up gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.4 + + - name: Run Android Unit Tests + run: | + gradle test + working-directory: ./tests/android + + - name: Run Android UI Tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + script: gradle connectedCheck + working-directory: ./tests/android diff --git a/src/android/OAuthPlugin.java b/src/android/OAuthPlugin.java index 830d0f8..0b05b88 100644 --- a/src/android/OAuthPlugin.java +++ b/src/android/OAuthPlugin.java @@ -1,5 +1,5 @@ /** - * Copyright 2019 Ayogo Health Inc. + * Copyright 2019 - 2022 Ayogo Health Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,12 @@ package com.ayogo.cordova.oauth; -import android.app.Activity; -import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; import android.net.Uri; import androidx.browser.customtabs.CustomTabsIntent; -import android.text.TextUtils; - -import java.util.ArrayList; -import java.util.List; import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaArgs; import org.apache.cordova.CordovaInterface; import org.apache.cordova.CordovaPlugin; import org.apache.cordova.CordovaWebView; @@ -37,7 +29,6 @@ import org.apache.cordova.LOG; import org.apache.cordova.PluginResult; -import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -45,22 +36,6 @@ public class OAuthPlugin extends CordovaPlugin { private final String TAG = "OAuthPlugin"; - // Taken from Google's CustomTabsHelper - // https://github.com/GoogleChrome/custom-tabs-client/blob/da65829d7d4b80c00809c6c4aa7f61f88fc7ca26/shared/src/main/java/org/chromium/customtabsclient/shared/CustomTabsHelper.java - static final String STABLE_PACKAGE = "com.android.chrome"; - static final String BETA_PACKAGE = "com.chrome.beta"; - static final String DEV_PACKAGE = "com.chrome.dev"; - static final String LOCAL_PACKAGE = "com.google.android.apps.chrome"; - private static final String EXTRA_CUSTOM_TABS_KEEP_ALIVE = "android.support.customtabs.extra.KEEP_ALIVE"; - private static final String ACTION_CUSTOM_TABS_CONNECTION = "android.support.customtabs.action.CustomTabsService"; - - - /** - * The name of the package to use for the custom tab service. - */ - private String tabProvider = null; - - /** * Executes the request. * @@ -76,7 +51,7 @@ public class OAuthPlugin extends CordovaPlugin { * @return Whether the action was valid. */ @Override - public boolean execute(String action, JSONArray args, CallbackContext callbackContext) { + public boolean execute(String action, CordovaArgs args, CallbackContext callbackContext) { if ("startOAuth".equals(action)) { try { String authEndpoint = args.getString(0); @@ -121,15 +96,7 @@ public void onNewIntent(Intent intent) { jsobj.put(queryKey, uri.getQueryParameter(queryKey)); } - final String msg = jsobj.toString(); - CordovaWebViewEngine engine = this.webView.getEngine(); - final String jsCode = "window.dispatchEvent(new MessageEvent('message', { data: 'oauth::" + msg + "' }));"; - if (engine != null) { - engine.evaluateJavascript(jsCode, null); - } else { - this.webView.sendJavascript(jsCode); - } - + dispatchOAuthMessage(jsobj.toString()); } catch (JSONException e) { LOG.e(TAG, "JSON Serialization failed"); e.printStackTrace(); @@ -144,112 +111,20 @@ public void onNewIntent(Intent intent) { * @param url The URL of the OAuth endpoint. */ private void startOAuth(String url) { - String customTabsBrowser = findCustomTabProvider(); - CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); CustomTabsIntent customTabsIntent = builder.build(); - - String packageName = this.findCustomTabProvider(); - if (packageName != null) { - customTabsIntent.intent.setPackage(packageName); - } - customTabsIntent.launchUrl(this.cordova.getActivity(), Uri.parse(url)); } + @SuppressWarnings("deprecation") + private void dispatchOAuthMessage(final String msg) { + final String jsCode = "window.dispatchEvent(new MessageEvent('message', { data: 'oauth::" + msg + "' }));"; - /** - * Goes through all apps that handle VIEW intents and have a warmup service. - * - * Picks the one chosen by the user if there is one, otherwise makes a best - * effort to return a valid package name. - * - * This is not threadsafe. - * - * @return The package name recommended to use for connecting to custom - * tabs related components. - */ - private String findCustomTabProvider() { - if (this.tabProvider != null) { - return this.tabProvider; - } - - PackageManager pm = this.cordova.getActivity().getPackageManager(); - - // Get default VIEW intent handler. - Intent activityIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com")); - ResolveInfo defaultViewHandlerInfo = pm.resolveActivity(activityIntent, 0); - String defaultViewHandlerPackageName = null; - - if (defaultViewHandlerInfo != null) { - defaultViewHandlerPackageName = defaultViewHandlerInfo.activityInfo.packageName; + CordovaWebViewEngine engine = this.webView.getEngine(); + if (engine != null) { + engine.evaluateJavascript(jsCode, null); + } else { + this.webView.sendJavascript(jsCode); } - - - // Get all apps that can handle VIEW intents. - List resolvedActivityList = pm.queryIntentActivities(activityIntent, PackageManager.MATCH_ALL); - List packagesSupportingCustomTabs = new ArrayList<>(); - - for (ResolveInfo info : resolvedActivityList) { - Intent serviceIntent = new Intent(); - serviceIntent.setAction(ACTION_CUSTOM_TABS_CONNECTION); - serviceIntent.setPackage(info.activityInfo.packageName); - - if (pm.resolveService(serviceIntent, 0) != null) { - packagesSupportingCustomTabs.add(info.activityInfo.packageName); - } - } - - // Now packagesSupportingCustomTabs contains all apps that can handle both VIEW intents - // and service calls. - if (packagesSupportingCustomTabs.isEmpty()) { - this.tabProvider = null; - } else if (packagesSupportingCustomTabs.size() == 1) { - this.tabProvider = packagesSupportingCustomTabs.get(0); - } else if (!TextUtils.isEmpty(defaultViewHandlerPackageName) && !this.hasSpecializedHandlerIntents(activityIntent) && packagesSupportingCustomTabs.contains(defaultViewHandlerPackageName)) { - this.tabProvider = defaultViewHandlerPackageName; - } else if (packagesSupportingCustomTabs.contains(STABLE_PACKAGE)) { - this.tabProvider = STABLE_PACKAGE; - } else if (packagesSupportingCustomTabs.contains(BETA_PACKAGE)) { - this.tabProvider = BETA_PACKAGE; - } else if (packagesSupportingCustomTabs.contains(DEV_PACKAGE)) { - this.tabProvider = DEV_PACKAGE; - } else if (packagesSupportingCustomTabs.contains(LOCAL_PACKAGE)) { - this.tabProvider = LOCAL_PACKAGE; - } - - return this.tabProvider; - } - - - /** - * Used to check whether there is a specialized handler for a given intent. - * - * @param intent The intent to check with. - * @return Whether there is a specialized handler for the given intent. - */ - private boolean hasSpecializedHandlerIntents(Intent intent) { - try { - PackageManager pm = this.cordova.getActivity().getPackageManager(); - List handlers = pm.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER); - - if (handlers == null || handlers.size() == 0) { - return false; - } - - for (ResolveInfo resolveInfo : handlers) { - IntentFilter filter = resolveInfo.filter; - - if (filter == null || filter.countDataAuthorities() == 0 || filter.countDataPaths() == 0 || resolveInfo.activityInfo == null) { - continue; - } - - return true; - } - } catch (RuntimeException e) { - LOG.e(TAG, "Runtime exception while getting specialized handlers"); - } - - return false; } } diff --git a/tests/android/.gitignore b/tests/android/.gitignore new file mode 100644 index 0000000..6b382aa --- /dev/null +++ b/tests/android/.gitignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +src/main/java/com/ayogo/cordova/oauth/OAuthPlugin.java +src/main/assets/ +src/main/res/ diff --git a/tests/android/build.gradle b/tests/android/build.gradle new file mode 100644 index 0000000..4f5d2ae --- /dev/null +++ b/tests/android/build.gradle @@ -0,0 +1,91 @@ +/** + * Copyright 2022 Ayogo Health Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.3.1' + } +} + +apply plugin: 'com.android.application' + +android { + compileSdkVersion 34 + + defaultConfig { + namespace 'com.ayogo.cordova.oauth.tests' + applicationId 'com.ayogo.cordova.oauth.tests' + minSdkVersion 24 + targetSdkVersion 34 + versionCode 1 + versionName '1.0' + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + } + + buildTypes { + debug { + testCoverageEnabled true + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + // These should get pulled in by the Cordova package, but don't :( + implementation 'androidx.appcompat:appcompat:[1.4.2,)' + implementation 'androidx.core:core-splashscreen:[1.0.0,)' + implementation 'androidx.webkit:webkit:[1.4.0,)' + + implementation 'org.apache.cordova:framework:[11.0.0,)' + implementation 'androidx.browser:browser:[1.3.0,)' + + testImplementation 'junit:junit:4.13.1' + testImplementation 'org.mockito:mockito-inline:4.6.1' + testImplementation 'org.json:json:[20220924,)' + + androidTestImplementation 'androidx.test:core:1.5.0' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' +} + +task copyPluginSource(type: Copy) { + from '../../src/android' + into 'src/main/java/com/ayogo/cordova/oauth' +} + +task copyExampleAssets(type: Copy) { + from '../../example/platforms/android/app/src/main/assets' + into 'src/main/assets' +} + +task copyExampleResources(type: Copy) { + from '../../example/platforms/android/app/src/main/res' + into 'src/main/res' +} + +preBuild.dependsOn(copyPluginSource) +preBuild.dependsOn(copyExampleAssets) +preBuild.dependsOn(copyExampleResources) diff --git a/tests/android/gradle.properties b/tests/android/gradle.properties new file mode 100644 index 0000000..d7ba8f4 --- /dev/null +++ b/tests/android/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX = true diff --git a/tests/android/src/androidTest/AndroidManifest.xml b/tests/android/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..00e4b9b --- /dev/null +++ b/tests/android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/tests/android/src/androidTest/java/com/ayogo/cordova/oauth/OAuthPluginTest.java b/tests/android/src/androidTest/java/com/ayogo/cordova/oauth/OAuthPluginTest.java new file mode 100644 index 0000000..d4b1fbc --- /dev/null +++ b/tests/android/src/androidTest/java/com/ayogo/cordova/oauth/OAuthPluginTest.java @@ -0,0 +1,100 @@ +/** + * Copyright 2022 Ayogo Health Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ayogo.cordova.oauth; + +import static org.junit.Assert.assertNotNull; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.webkit.WebView; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.By; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiObject2; +import androidx.test.uiautomator.Until; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class OAuthPluginTest { + static final long TIMEOUT = 5000; + static final String TEST_PACKAGE = "com.ayogo.cordova.oauth.tests"; + + private UiDevice device; + + @Before + public void waitForAppLaunch() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); + + device.pressHome(); + + final String launcherPackage = getLauncherPackageName(); + assertNotNull(launcherPackage); + device.wait(Until.hasObject(By.pkg(launcherPackage).depth(0)), TIMEOUT); + + Context context = ApplicationProvider.getApplicationContext(); + final Intent intent = context.getPackageManager().getLaunchIntentForPackage(TEST_PACKAGE); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + context.startActivity(intent); + + device.wait(Until.hasObject(By.pkg(TEST_PACKAGE).depth(0)), TIMEOUT); + } + + @Test + public void testOAuthFlow() { + assertNotNull(device); + + device.wait(Until.findObject(By.clazz(WebView.class)), TIMEOUT); + + UiObject2 loginBtn = device.wait(Until.findObject(By.text("Sign in with OAuth")), TIMEOUT); + assertNotNull(loginBtn); + loginBtn.click(); + + // Now we have to interact with the Chrome Custom Tab + UiObject2 oauthBtn = device.wait(Until.findObject(By.text("Click Here to Login")), TIMEOUT); + assertNotNull(oauthBtn); + oauthBtn.click(); + + // Should be back in the app now + device.wait(Until.findObject(By.clazz(WebView.class)), TIMEOUT); + + device.wait(Until.findObject(By.text("LOGGED IN")), TIMEOUT); + } + + /** + * Uses package manager to find the package name of the device launcher. Usually this package + * is "com.android.launcher" but can be different at times. This is a generic solution which + * works on all platforms.` + */ + @SuppressWarnings("deprecation") + private String getLauncherPackageName() { + // Create launcher Intent + final Intent intent = new Intent(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_HOME); + + // Use PackageManager to get the launcher package name + PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager(); + ResolveInfo resolveInfo = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); + return resolveInfo.activityInfo.packageName; + } +} diff --git a/tests/android/src/main/AndroidManifest.xml b/tests/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..114fee5 --- /dev/null +++ b/tests/android/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/android/src/main/java/com/ayogo/cordova/oauth/tests/MainActivity.java b/tests/android/src/main/java/com/ayogo/cordova/oauth/tests/MainActivity.java new file mode 100644 index 0000000..f1b5d06 --- /dev/null +++ b/tests/android/src/main/java/com/ayogo/cordova/oauth/tests/MainActivity.java @@ -0,0 +1,28 @@ +/** + * Copyright 2022 Ayogo Health Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ayogo.cordova.oauth.tests; + +import android.os.Bundle; +import org.apache.cordova.CordovaActivity; + +public class MainActivity extends CordovaActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + loadUrl(launchUrl); + } +} diff --git a/tests/android/src/test/java/com/ayogo/cordova/oauth/OAuthPluginUnitTest.java b/tests/android/src/test/java/com/ayogo/cordova/oauth/OAuthPluginUnitTest.java new file mode 100644 index 0000000..b5f86aa --- /dev/null +++ b/tests/android/src/test/java/com/ayogo/cordova/oauth/OAuthPluginUnitTest.java @@ -0,0 +1,59 @@ +/** + * Copyright 2022 Ayogo Health Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ayogo.cordova.oauth; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaArgs; +import org.apache.cordova.PluginResult; +import org.json.JSONException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class OAuthPluginUnitTest { + @Test + public void executeWithUnknownAction() throws JSONException { + OAuthPlugin plugin = new OAuthPlugin(); + + CallbackContext context = mock(CallbackContext.class); + boolean retVal = plugin.execute("unknownAction", "[]", context); + assertEquals(false, retVal); + + ArgumentCaptor result = ArgumentCaptor.forClass(PluginResult.class); + verify(context).sendPluginResult(result.capture()); + assertEquals(PluginResult.Status.INVALID_ACTION.ordinal(), result.getValue().getStatus()); + } + + @Test + public void executeOAuthWithMissingArgument() throws JSONException { + OAuthPlugin plugin = new OAuthPlugin(); + + CallbackContext context = mock(CallbackContext.class); + boolean retVal = plugin.execute("startOAuth", "[]", context); + assertEquals(false, retVal); + + ArgumentCaptor result = ArgumentCaptor.forClass(PluginResult.class); + verify(context).sendPluginResult(result.capture()); + assertEquals(PluginResult.Status.ERROR.ordinal(), result.getValue().getStatus()); + } +} diff --git a/tests/ios/Tests/Tests.swift b/tests/ios/Tests/Tests.swift index d9c6d82..38321e5 100644 --- a/tests/ios/Tests/Tests.swift +++ b/tests/ios/Tests/Tests.swift @@ -118,7 +118,7 @@ class OAuthPluginTests: XCTestCase { func testOAuthCommandURL() throws { plugin.pluginInitialize() - let nonURLcmd = CDVInvokedUrlCommand(arguments:["Hello world!"], callbackId:"", className:"CDVOAuthPlugin", methodName:"startOAuth") + let nonURLcmd = CDVInvokedUrlCommand(arguments:[""], callbackId:"", className:"CDVOAuthPlugin", methodName:"startOAuth") plugin.startOAuth(nonURLcmd!) XCTAssertEqual(cmdDlg.lastResult.status as! UInt, CDVCommandStatus.error.rawValue) } diff --git a/tests/ios/UITests/UITests.swift b/tests/ios/UITests/UITests.swift index ed6050f..00c9dd7 100644 --- a/tests/ios/UITests/UITests.swift +++ b/tests/ios/UITests/UITests.swift @@ -59,7 +59,7 @@ class OAuthPluginUITests: XCTestCase { // Verify the app received the OAuth token and considers us logged in let loggedIn = app.staticTexts["LOGGED IN"] - _ = loggedIn.waitForExistence(timeout: 5) + _ = loggedIn.waitForExistence(timeout: 45) XCTAssert(loggedIn.exists) } @@ -71,7 +71,7 @@ class OAuthPluginUITests: XCTestCase { } let oauthButton = app.webViews.buttons["Click Here to Login"] - _ = oauthButton.waitForExistence(timeout: 5) + _ = oauthButton.waitForExistence(timeout: 25) XCTAssert(oauthButton.exists) XCTAssert(oauthButton.isHittable) oauthButton.tap()