Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(android)(9_3_X): honor Android 11 "package visibility" #11985

Merged
merged 3 commits into from
Oct 26, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 23 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.titanium.test">
<!-- Permissions added to all Titanium apps by default. -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

<!-- Permissions needed to test Ti.Geolocation module. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>

<!-- Allows this app to detect other apps which support below intents on Android 11+. -->
<queries>
<!-- Needed to make Ti.Platform.canOpenURL() work with these URLS schemes. -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="mailto"/>
</intent>
<!-- Needed to make Ti.UI.EmailDialog.isSupported() work. -->
<intent>
<action android:name="android.intent.action.SEND"/>
<data android:mimeType="message/rfc822"/>
</intent>
</queries>

<application android:name=".TitaniumTestApplication" android:icon="@drawable/appicon" android:label="TitaniumTest" android:theme="@style/Theme.AppCompat" android:usesCleartextTraffic="true">
<!-- The root Titanium splash activity which hosts the JS runtime. -->
<activity android:name=".TitaniumTestActivity" android:theme="@style/Theme.Titanium" android:alwaysRetainTaskState="true" android:configChanges="${tiActivityConfigChanges}">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
Expand Down
90 changes: 61 additions & 29 deletions android/cli/commands/_build.js
Original file line number Diff line number Diff line change
Expand Up @@ -3444,11 +3444,10 @@ AndroidBuilder.prototype.generateTheme = async function generateTheme() {
await fs.writeFile(xmlFilePath, xmlLines.join('\n'));
};

AndroidBuilder.prototype.fetchNeededAndroidPermissions = function fetchNeededAndroidPermissions() {
// Do not continue if permission injection has been disabled in "tiapp.xml".
if (this.tiapp['override-permissions']) {
return [];
}
AndroidBuilder.prototype.fetchNeededManifestSettings = function fetchNeededManifestSettings() {
// Check if permission injection is disabled in "tiapp.xml".
// Note: Recommended solution is to use 'tools:node="remove"' attributes within <manifest/> instead.
const canAddPermissions = !this.tiapp['override-permissions'];

// Define Android <uses-permission/> names needed by our core Titanium APIs.
const calendarPermissions = [ 'android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR' ];
Expand Down Expand Up @@ -3488,20 +3487,32 @@ AndroidBuilder.prototype.fetchNeededAndroidPermissions = function fetchNeededAnd
// Add Titanium's default permissions.
// Note: You would normally define needed permissions in AAR library's manifest file,
// but we want "tiapp.xml" property "override-permissions" to be able to override this behavior.
const neededPermissionDictionary = {
'android.permission.INTERNET': true,
'android.permission.ACCESS_WIFI_STATE': true,
'android.permission.ACCESS_NETWORK_STATE': true,
'android.permission.WRITE_EXTERNAL_STORAGE': true
const neededPermissionDictionary = {};
if (canAddPermissions) {
neededPermissionDictionary['android.permission.INTERNET'] = true;
neededPermissionDictionary['android.permission.ACCESS_WIFI_STATE'] = true;
neededPermissionDictionary['android.permission.ACCESS_NETWORK_STATE'] = true;
neededPermissionDictionary['android.permission.WRITE_EXTERNAL_STORAGE'] = true;
}

// Define JavaScript methods that need manifest <queries> entries.
// The value strings are used as boolean property names in our "AndroidManifest.xml" EJS template.
const tiMethodQueries = {
'UI.createEmailDialog': 'sendEmail',
'UI.EmailDialog': 'sendEmail'
};

// To be populated with <queries/> needed by the app.
// Uses the string values from "tiMethodQueries" as keys.
const neededQueriesDictionary = {};

// Make sure Titanium symbols variable "tiSymbols" is valid.
if (!this.tiSymbols) {
this.tiSymbols = {};
}

// Traverse all accessed namespaces/methods in JavaScript.
// Add any Android permissions needed if matching the above mappings.
// Add any Android permissions/queries needed if matching the above mappings.
const accessedSymbols = {};
for (const file in this.tiSymbols) {
// Fetch all symbols from the next JavaScript file.
Expand All @@ -3518,29 +3529,48 @@ AndroidBuilder.prototype.fetchNeededAndroidPermissions = function fetchNeededAnd
}
accessedSymbols[symbol] = true;

// If symbol is a namespace, then check if it needs permission.
// Note: Check each namespace component separately, split via periods.
const namespaceParts = symbol.split('.').slice(0, -1);
for (;namespaceParts.length > 0; namespaceParts.pop()) {
const namespace = namespaceParts.join('.');
if (namespace && tiNamespacePermissions[namespace]) {
for (const permission of tiNamespacePermissions[namespace]) {
// Check if symbol requires any Android permissions.
if (canAddPermissions) {
let permissionArray;

// If symbol is a namespace, then check if it needs permission.
// Note: Check each namespace component separately, split via periods.
const namespaceParts = symbol.split('.').slice(0, -1);
for (;namespaceParts.length > 0; namespaceParts.pop()) {
const namespace = namespaceParts.join('.');
if (namespace) {
permissionArray = tiNamespacePermissions[namespace];
if (permissionArray) { // eslint-disable-line max-depth
for (const permission of permissionArray) { // eslint-disable-line max-depth
neededPermissionDictionary[permission] = true;
}
}
}
}

// If symbol is a method, then check if it needs permission.
permissionArray = tiMethodPermissions[symbol];
if (permissionArray) {
for (const permission of permissionArray) {
neededPermissionDictionary[permission] = true;
}
}
}

// If symbol is a method, then check if it needs permission.
if (tiMethodPermissions[symbol]) {
for (const permission of tiMethodPermissions[symbol]) {
neededPermissionDictionary[permission] = true;
}
// Check if symbol requires an Android <queries/> entry.
const queryName = tiMethodQueries[symbol];
if (queryName) {
neededQueriesDictionary[queryName] = true;
}
}
}

// Return an array of Android <uses-permission/> names needed.
return Object.keys(neededPermissionDictionary);
// Return the entries needed to be injected into the generated "AndroidManifest.xml" file.
const neededSettings = {
usesPermissions: Object.keys(neededPermissionDictionary),
queries: neededQueriesDictionary
};
return neededSettings;
};

AndroidBuilder.prototype.generateAndroidManifest = async function generateAndroidManifest() {
Expand Down Expand Up @@ -3631,6 +3661,9 @@ AndroidBuilder.prototype.generateAndroidManifest = async function generateAndroi
}
}

// Scan app's JS code to see what <uses-permission/> and <queries/> entries should be auto-injected into manifest.
const neededManifestSettings = this.fetchNeededManifestSettings();

// Generate the app's main manifest from EJS template.
let mainManifestContent = await fs.readFile(path.join(this.templatesDir, 'AndroidManifest.xml'));
mainManifestContent = ejs.render(mainManifestContent.toString(), {
Expand All @@ -3639,13 +3672,12 @@ AndroidBuilder.prototype.generateAndroidManifest = async function generateAndroi
appLabel: this.tiapp.name,
appTheme: appThemeName,
classname: this.classname,
packageName: this.appid
packageName: this.appid,
queries: neededManifestSettings.queries,
usesPermissions: neededManifestSettings.usesPermissions
});
const mainManifest = AndroidManifest.fromXmlString(mainManifestContent);

// Add <uses-permission/> needed by Titanium. Will add permissions based on JS APIs used such as geolocation.
mainManifest.addUsesPermissions(this.fetchNeededAndroidPermissions());

// Write the main "AndroidManifest.xml" file providing Titanium's default app manifest settings.
const mainManifestFilePath = path.join(this.buildAppMainDir, 'AndroidManifest.xml');
await new Promise((resolve) => {
Expand Down
10 changes: 7 additions & 3 deletions android/cli/lib/android-manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -502,10 +502,13 @@ class AndroidManifest {
return;
}

// We only support merging child nodes immediately under <manifest/> or <application/> elements.
// We only support merging child nodes immediately under <manifest/>, <queries/>, or <application/>.
// For all other XML elements, we simply replace the child nodes, but only if children were provided.
const isManifestElement = (sourceElement.tagName === 'manifest');
const canMergeChildren = isManifestElement || (sourceElement.tagName === 'application');
const canMergeChildren
= isManifestElement
|| (sourceElement.tagName === 'application')
|| (sourceElement.tagName === 'queries');
if (!canMergeChildren) {
while (destinationElement.hasChildNodes()) {
destinationElement.removeChild(destinationElement.firstChild);
Expand All @@ -531,8 +534,9 @@ class AndroidManifest {
}

// Attempt to find a matching child element under destination.
// Note: Never merge <intent/> block. Only append them. (Duplicate intent blocks are okay.)
let destinationChildElement = null;
if (tagName) {
if (tagName && (tagName !== 'intent')) {
if (androidName) {
destinationChildElement = getFirstChildElementByTagAndAndroidName(destinationElement, tagName, androidName);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,77 +245,17 @@ public boolean canOpenURL(KrollInvocation invocation, String url)
}

// Determine if the system has a registered activity intent-filter for the given URL.
Intent intent = createOpenUrlIntentFrom(invocation, url);
return canOpen(intent);
}

private boolean canOpen(Intent intent)
{
// Validate argument.
if (intent == null) {
return false;
}

// If the intent references a local file, then make sure it exists.
Uri uri = intent.getData();
String scheme = (uri != null) ? uri.getScheme() : null;
if (scheme != null) {
if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
// We were given a "content://" URL. Check if its ContentProvider can provide file access.
// Note: Will typically throw a "FileNotFoundException" or return null if file doesn't exist.
ContentResolver contentResolver = TiApplication.getInstance().getContentResolver();
if (contentResolver != null) {
// First, check if we're referencing an existing file embedded within a file.
// Example: A file under the APK's "assets" or "res" folder.
boolean wasFileFound = false;
try (AssetFileDescriptor descriptor = contentResolver.openAssetFileDescriptor(uri, "r")) {
wasFileFound = (descriptor != null);
} catch (Exception ex) {
}

// If above failed, check if referencing an existing sandboxed file in the file system.
if (wasFileFound == false) {
try (ParcelFileDescriptor descriptor = contentResolver.openFileDescriptor(uri, "r")) {
wasFileFound = (descriptor != null);
} catch (Exception ex) {
}
}

// If above failed, then check if we can open a file stream. (The most expensive check.)
// This can happen with in-memory files or decoded files.
if (wasFileFound == false) {
try (InputStream stream = contentResolver.openInputStream(uri)) {
wasFileFound = (stream != null);
} catch (Exception ex) {
}
}

// Do not continue if cannot access file via ContentProvider.
if (wasFileFound == false) {
return false;
}
}
} else if (scheme.equals(ContentResolver.SCHEME_FILE)) {
// We were given a "file://" URL. Check if it exists in file system.
File file = new File(uri.getPath());
if (file.exists() == false) {
return false;
}
}
}

// Check if there is at least 1 activity registered into the system that can open the given intent.
// Note: This means the activity has to have a matching intent-filter in the app's "AndroidManifest.xml".
boolean canOpen = false;
try {
PackageManager packageManager = TiApplication.getInstance().getPackageManager();
if (intent.resolveActivity(packageManager) != null) {
canOpen = true;
Intent intent = createOpenUrlIntentFrom(invocation, url);
if (hasValidFileReference(intent)) {
PackageManager packageManager = TiApplication.getInstance().getPackageManager();
if (intent.resolveActivity(packageManager) != null) {
canOpen = true;
}
}
} catch (Exception ex) {
}

// Returns true if given URL can be opened by our openURL() method.
return canOpen;
}

Expand Down Expand Up @@ -372,8 +312,8 @@ public boolean openURL(KrollInvocation invocation, String url,
return false;
}

// Do not continue if system cannot open the given URL/intent.
if (canOpen(intent) == false) {
// If intent references a local file, then make sure it exists.
if (hasValidFileReference(intent) == false) {
return false;
}

Expand Down Expand Up @@ -743,6 +683,72 @@ private Intent createOpenUrlIntentFrom(KrollInvocation invocation, String url)
return intent;
}

/**
* If given intent references a local file, then this method checks if it exists.
* @param intent The intent to be validated. Can be null.
* @return
* Returns true if intent's reference file exists or if intent does not reference a file.
* Returns false if intent's referenced file does not exist or if given a null argument.
*/
private boolean hasValidFileReference(Intent intent)
{
// Validate argument.
if (intent == null) {
return false;
}

// If the intent references a local file, then make sure it exists.
Uri uri = intent.getData();
String scheme = (uri != null) ? uri.getScheme() : null;
if (scheme != null) {
if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
// We were given a "content://" URL. Check if its ContentProvider can provide file access.
// Note: Will typically throw a "FileNotFoundException" or return null if file doesn't exist.
ContentResolver contentResolver = TiApplication.getInstance().getContentResolver();
if (contentResolver != null) {
// First, check if we're referencing an existing file embedded within a file.
// Example: A file under the APK's "assets" or "res" folder.
boolean wasFileFound = false;
try (AssetFileDescriptor descriptor = contentResolver.openAssetFileDescriptor(uri, "r")) {
wasFileFound = (descriptor != null);
} catch (Exception ex) {
}

// If above failed, check if referencing an existing sandboxed file in the file system.
if (wasFileFound == false) {
try (ParcelFileDescriptor descriptor = contentResolver.openFileDescriptor(uri, "r")) {
wasFileFound = (descriptor != null);
} catch (Exception ex) {
}
}

// If above failed, then check if we can open a file stream. (The most expensive check.)
// This can happen with in-memory files or decoded files.
if (wasFileFound == false) {
try (InputStream stream = contentResolver.openInputStream(uri)) {
wasFileFound = (stream != null);
} catch (Exception ex) {
}
}

// Do not continue if cannot access file via ContentProvider.
if (wasFileFound == false) {
return false;
}
}
} else if (scheme.equals(ContentResolver.SCHEME_FILE)) {
// We were given a "file://" URL. Check if it exists in file system.
File file = new File(uri.getPath());
if (file.exists() == false) {
return false;
}
}
}

// Intent references an existing file or does not reference a file at all.
return true;
}

private static class Processor
{
private Map<String, String> details;
Expand Down