Release Android Preview 21 and bilingual changelog
Add permission-based reading of Android contacts, SMS and call history. Include shared Gaia fixes and document device validation limits. Pixel image qualification remains pending.
This commit is contained in:
parent
1c20a0b6d5
commit
200d1fb235
43 changed files with 2271 additions and 65 deletions
|
|
@ -1,5 +1,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-feature android:name="android.hardware.telephony" android:required="false" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
package org.vulpes_os.preview;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.database.Cursor;
|
||||
import android.database.ContentObserver;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import android.net.Uri;
|
||||
import android.provider.CallLog;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.Telephony;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** Read-through access. Android remains the owner of contacts and telephony records. */
|
||||
final class AndroidPersonalData {
|
||||
private final Context context;
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final Map<String, ContentObserver> observers = new HashMap<>();
|
||||
private final Map<String, Runnable> pending = new HashMap<>();
|
||||
void changed(String kind) {
|
||||
Runnable previous = pending.remove(kind);
|
||||
if (previous != null) main.removeCallbacks(previous);
|
||||
Runnable next = () -> {
|
||||
pending.remove(kind);
|
||||
VulpesApplication app = (VulpesApplication) context;
|
||||
if (app.port == null) return;
|
||||
try { app.port.postMessage(new JSONObject().put("type", "android-data-changed").put("kind", kind)); }
|
||||
catch (Exception ignored) { /* The resume event refreshes detached sessions. */ }
|
||||
};
|
||||
pending.put(kind, next);
|
||||
main.postDelayed(next, 250);
|
||||
}
|
||||
void observe() {
|
||||
for (String kind : new String[]{"contacts", "sms", "calls"}) {
|
||||
if (granted(kind) && !observers.containsKey(kind)) {
|
||||
ContentObserver observer = new ContentObserver(main) {
|
||||
@Override public void onChange(boolean selfChange) { changed(kind); }
|
||||
};
|
||||
Uri uri = kind.equals("contacts") ? ContactsContract.Contacts.CONTENT_URI :
|
||||
kind.equals("sms") ? Telephony.Sms.CONTENT_URI : CallLog.Calls.CONTENT_URI;
|
||||
try {
|
||||
context.getContentResolver().registerContentObserver(uri, true, observer);
|
||||
observers.put(kind, observer);
|
||||
} catch (SecurityException ignored) { /* Permission can be revoked during registration. */ }
|
||||
} else if (!granted(kind) && observers.containsKey(kind)) {
|
||||
context.getContentResolver().unregisterContentObserver(observers.remove(kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AndroidPersonalData(Context context) { this.context = context; }
|
||||
|
||||
static String permission(String kind) {
|
||||
switch (kind) {
|
||||
case "contacts": return Manifest.permission.READ_CONTACTS;
|
||||
case "sms": return Manifest.permission.READ_SMS;
|
||||
case "calls": return Manifest.permission.READ_CALL_LOG;
|
||||
default: throw new IllegalArgumentException("INVALID_DATA_KIND");
|
||||
}
|
||||
}
|
||||
|
||||
boolean granted(String kind) {
|
||||
return context.getSharedPreferences("personal-data", 0).getBoolean(kind, false) &&
|
||||
context.checkSelfPermission(permission(kind)) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
void enable(String kind, boolean value) {
|
||||
permission(kind);
|
||||
context.getSharedPreferences("personal-data", 0).edit().putBoolean(kind, value).apply();
|
||||
observe();
|
||||
changed(kind);
|
||||
}
|
||||
|
||||
JSONObject status() throws Exception {
|
||||
JSONObject result = new JSONObject();
|
||||
for (String kind : new String[]{"contacts", "sms", "calls"}) result.put(kind, granted(kind));
|
||||
return result;
|
||||
}
|
||||
|
||||
JSONObject page(JSONObject params) throws Exception {
|
||||
String kind = params.getString("kind");
|
||||
if (!granted(kind)) throw new SecurityException("ANDROID_DATA_PERMISSION_DENIED");
|
||||
long after = params.optLong("after", 0);
|
||||
if (after < 0) throw new IllegalArgumentException("INVALID_CURSOR");
|
||||
Uri uri;
|
||||
String[] columns;
|
||||
switch (kind) {
|
||||
case "contacts":
|
||||
uri = ContactsContract.Contacts.CONTENT_URI;
|
||||
columns = new String[]{"_id", "display_name", "starred", "contact_last_updated_timestamp"};
|
||||
break;
|
||||
case "sms":
|
||||
uri = Telephony.Sms.CONTENT_URI;
|
||||
columns = new String[]{"_id", "thread_id", "address", "body", "date", "type", "read", "status"};
|
||||
break;
|
||||
default:
|
||||
uri = CallLog.Calls.CONTENT_URI;
|
||||
columns = new String[]{"_id", "number", "date", "duration", "type"};
|
||||
}
|
||||
JSONArray rows = new JSONArray();
|
||||
long last = after;
|
||||
boolean more = false;
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
// A key cursor avoids SQL LIMIT syntax differences between Android providers.
|
||||
try (Cursor cursor = resolver.query(uri, columns, "_id > ?", new String[]{Long.toString(after)}, "_id ASC")) {
|
||||
if (cursor == null) throw new IllegalStateException("ANDROID_PROVIDER_UNAVAILABLE");
|
||||
int bytes = 0;
|
||||
while (cursor.moveToNext()) {
|
||||
if (rows.length() >= 100 || bytes > 512 * 1024) { more = true; break; }
|
||||
JSONObject row = new JSONObject();
|
||||
for (int i = 0; i < columns.length; i++) {
|
||||
row.put(columns[i], cursor.isNull(i) ? JSONObject.NULL :
|
||||
cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER ? cursor.getLong(i) : cursor.getString(i));
|
||||
}
|
||||
last = cursor.getLong(0);
|
||||
if (kind.equals("contacts")) contactDetails(last, row);
|
||||
rows.put(row);
|
||||
bytes += row.toString().length() * 2;
|
||||
}
|
||||
}
|
||||
return new JSONObject().put("rows", rows).put("next", more ? last : JSONObject.NULL);
|
||||
}
|
||||
|
||||
private void contactDetails(long id, JSONObject contact) throws Exception {
|
||||
JSONArray phones = new JSONArray(), emails = new JSONArray();
|
||||
contact.put("tel", phones).put("email", emails);
|
||||
try (Cursor c = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI,
|
||||
new String[]{"mimetype", "data1", "data2", "data3", "is_primary"},
|
||||
"contact_id = ? AND mimetype IN (?, ?, ?)", new String[]{Long.toString(id),
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE}, "_id ASC")) {
|
||||
if (c == null) throw new IllegalStateException("ANDROID_PROVIDER_UNAVAILABLE");
|
||||
while (c.moveToNext()) {
|
||||
String mime = c.getString(0);
|
||||
if (mime.equals(ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) {
|
||||
if (!contact.has("givenName")) contact.put("givenName", new JSONArray().put(c.getString(2)))
|
||||
.put("familyName", new JSONArray().put(c.getString(3)));
|
||||
} else {
|
||||
boolean phone = mime.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE);
|
||||
int type = c.getInt(2);
|
||||
String label = type == 0 ? c.getString(3) : phone ?
|
||||
(type == 2 ? "mobile" : type == 1 ? "home" : type == 3 ? "work" : "other") :
|
||||
(type == 1 ? "home" : type == 2 ? "work" : "other");
|
||||
(phone ? phones : emails).put(new JSONObject().put("value", c.getString(1))
|
||||
.put("type", new JSONArray().put(label == null ? "other" : label)).put("pref", c.getInt(4) != 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Intent open(JSONObject params) {
|
||||
String kind = params.optString("kind");
|
||||
if (kind.equals("contacts")) {
|
||||
String id = params.optString("id");
|
||||
if (!id.matches("[1-9][0-9]{0,15}")) throw new IllegalArgumentException("INVALID_CONTACT");
|
||||
if (!granted(kind)) throw new SecurityException("ANDROID_DATA_PERMISSION_DENIED");
|
||||
return new Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, Long.parseLong(id)));
|
||||
}
|
||||
if (kind.equals("sms")) return new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_APP_MESSAGING);
|
||||
if (kind.equals("calls")) return new Intent(Intent.ACTION_VIEW, CallLog.Calls.CONTENT_URI);
|
||||
throw new IllegalArgumentException("INVALID_DATA_KIND");
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ final class AndroidServices {
|
|||
CONTACT = 412;
|
||||
static final long MAX_MEDIA = 32L * 1024 * 1024;
|
||||
final VulpesApplication app;
|
||||
final AndroidPersonalData personal;
|
||||
final ExecutorService io = Executors.newSingleThreadExecutor();
|
||||
final Handler main = new Handler(Looper.getMainLooper());
|
||||
GeckoResult<Object> pending;
|
||||
|
|
@ -31,6 +32,7 @@ final class AndroidServices {
|
|||
|
||||
AndroidServices(VulpesApplication app) {
|
||||
this.app = app;
|
||||
personal = new AndroidPersonalData(app);
|
||||
}
|
||||
|
||||
GeckoResult<Object> request(JSONObject message) {
|
||||
|
|
@ -40,6 +42,30 @@ final class AndroidServices {
|
|||
JSONObject p = message.optJSONObject("params");
|
||||
if (p == null) p = new JSONObject();
|
||||
switch (op) {
|
||||
case "android.personal":
|
||||
JSONObject personalParams = p;
|
||||
String operation = p.optString("operation");
|
||||
if (operation.equals("permission")) {
|
||||
String kind = p.getString("kind");
|
||||
activity().permission(new String[]{AndroidPersonalData.permission(kind)}, granted -> {
|
||||
try { personal.enable(kind, granted); result.complete(personal.status()); } catch (Exception e) { fail(result, e); }
|
||||
});
|
||||
} else if (operation.equals("status")) result.complete(personal.status());
|
||||
else if (operation.equals("disconnect")) {
|
||||
personal.enable(p.getString("kind"), false);
|
||||
result.complete(personal.status());
|
||||
}
|
||||
else if (operation.equals("open")) {
|
||||
activity().startActivity(personal.open(p));
|
||||
result.complete(null);
|
||||
} else if (operation.equals("page")) io.execute(() -> {
|
||||
try {
|
||||
JSONObject data = personal.page(personalParams);
|
||||
main.post(() -> result.complete(data));
|
||||
} catch (Exception e) { fail(result, e); }
|
||||
});
|
||||
else throw new IllegalArgumentException("METHOD_NOT_SUPPORTED");
|
||||
break;
|
||||
case "android.notification":
|
||||
JSONObject request = p;
|
||||
if (p.optString("operation").equals("permission")) {
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ public final class MainActivity extends Activity {
|
|||
webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52)));
|
||||
Button back = new Button(this);
|
||||
back.setText("‹");
|
||||
back.setContentDescription("Retour au bureau");
|
||||
back.setOnClickListener(v -> closeBrowser());
|
||||
back.setContentDescription("Retour");
|
||||
back.setOnClickListener(v -> back());
|
||||
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
|
||||
address = new EditText(this);
|
||||
address.setSingleLine(true);
|
||||
|
|
@ -99,6 +99,9 @@ public final class MainActivity extends Activity {
|
|||
return true;
|
||||
});
|
||||
webView = new GeckoView(this);
|
||||
// The web panel is layered over Gaia and hidden when returning to an app.
|
||||
// A TextureView follows that visibility without leaving a separate surface.
|
||||
webView.setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW);
|
||||
webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
|
||||
home = new Button(this);
|
||||
home.setText("◯");
|
||||
|
|
@ -297,10 +300,12 @@ public final class MainActivity extends Activity {
|
|||
}
|
||||
|
||||
void closeBrowser() {
|
||||
boolean wasVisible = webPanel.getVisibility() == View.VISIBLE;
|
||||
webPanel.setVisibility(View.GONE);
|
||||
gaiaView.setVisibility(View.VISIBLE);
|
||||
setHomeSurface(app.homeVisible);
|
||||
syncActive();
|
||||
if (wasVisible) app.browserClosed();
|
||||
}
|
||||
|
||||
private void back() {
|
||||
|
|
@ -470,7 +475,7 @@ public final class MainActivity extends Activity {
|
|||
else callback.accept(granted);
|
||||
}
|
||||
|
||||
private void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
|
||||
void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
|
||||
boolean granted = true;
|
||||
for (String name : names)
|
||||
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ public final class VulpesApplication extends Application {
|
|||
);
|
||||
}
|
||||
|
||||
void browserClosed() {
|
||||
if (port == null) return;
|
||||
try {
|
||||
port.postMessage(new JSONObject().put("type", "browser-closed"));
|
||||
} catch (Exception e) {
|
||||
Log.w("Vulpes", "Browser close notification", e);
|
||||
}
|
||||
}
|
||||
|
||||
void home(boolean held) {
|
||||
if (port == null) return;
|
||||
try {
|
||||
|
|
@ -160,6 +169,7 @@ public final class VulpesApplication extends Application {
|
|||
}
|
||||
|
||||
void resumed() {
|
||||
if (services != null) services.personal.observe();
|
||||
sendScreenLock();
|
||||
if (port == null) return;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue