diff --git a/client/capacitor/android/app/build.gradle b/client/capacitor/android/app/build.gradle index 2cab90913b9..1fc7451e9b7 100644 --- a/client/capacitor/android/app/build.gradle +++ b/client/capacitor/android/app/build.gradle @@ -32,7 +32,7 @@ android { versionCode 1 versionName "1.0" } - + buildTypes { release { minifyEnabled false diff --git a/client/capacitor/android/app/src/main/AndroidManifest.xml b/client/capacitor/android/app/src/main/AndroidManifest.xml index 5bfcf272e1e..28d08ef3177 100644 --- a/client/capacitor/android/app/src/main/AndroidManifest.xml +++ b/client/capacitor/android/app/src/main/AndroidManifest.xml @@ -16,8 +16,12 @@ --> + + + + + + + diff --git a/client/capacitor/android/app/src/main/java/org/outline/client/MainActivity.java b/client/capacitor/android/app/src/main/java/org/outline/client/MainActivity.java index 6ade6794f4a..7b6caf49f87 100644 --- a/client/capacitor/android/app/src/main/java/org/outline/client/MainActivity.java +++ b/client/capacitor/android/app/src/main/java/org/outline/client/MainActivity.java @@ -16,6 +16,29 @@ package org.outline.client; +import android.app.Activity; +import android.app.UiModeManager; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.os.Bundle; + import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + if (isAndroidTv()) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } + super.onCreate(savedInstanceState); + } + + private boolean isAndroidTv() { + UiModeManager uiModeManager = + (UiModeManager) getSystemService(Activity.UI_MODE_SERVICE); + return (uiModeManager != null + && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) + || getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); + } +} diff --git a/client/capacitor/android/app/src/main/res/drawable-xhdpi/tv_banner.png b/client/capacitor/android/app/src/main/res/drawable-xhdpi/tv_banner.png new file mode 100644 index 00000000000..1a69a3c6239 Binary files /dev/null and b/client/capacitor/android/app/src/main/res/drawable-xhdpi/tv_banner.png differ diff --git a/client/capacitor/plugins/capacitor-plugin-outline/android/src/main/java/org/outline/CapacitorPluginOutline.kt b/client/capacitor/plugins/capacitor-plugin-outline/android/src/main/java/org/outline/CapacitorPluginOutline.kt index 866ad79a0bb..175cd1c364e 100644 --- a/client/capacitor/plugins/capacitor-plugin-outline/android/src/main/java/org/outline/CapacitorPluginOutline.kt +++ b/client/capacitor/plugins/capacitor-plugin-outline/android/src/main/java/org/outline/CapacitorPluginOutline.kt @@ -15,12 +15,15 @@ package org.outline import android.app.Activity +import android.app.UiModeManager import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.content.res.Configuration import android.net.VpnService import android.os.Handler import android.os.IBinder @@ -154,6 +157,15 @@ class CapacitorPluginOutline : Plugin() { super.handleOnDestroy() } + @PluginMethod + fun isAndroidTv(call: PluginCall) { + val uiModeManager = baseContext().getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager + val isAndroidTv = + (uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) || + baseContext().packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + call.resolve(JSObject().apply { put("isAndroidTv", isAndroidTv) }) + } + @PluginMethod fun invokeMethod(call: PluginCall) { executor.execute { diff --git a/client/capacitor/plugins/capacitor-plugin-outline/src/definitions.ts b/client/capacitor/plugins/capacitor-plugin-outline/src/definitions.ts index b2c5c553d0b..8bb8c8bd1e0 100644 --- a/client/capacitor/plugins/capacitor-plugin-outline/src/definitions.ts +++ b/client/capacitor/plugins/capacitor-plugin-outline/src/definitions.ts @@ -17,6 +17,7 @@ import type {PluginListenerHandle} from '@capacitor/core'; export interface CapacitorPluginOutline { + isAndroidTv(): Promise<{isAndroidTv: boolean}>; invokeMethod(options: { method: string; input: string; diff --git a/client/capacitor/plugins/capacitor-plugin-outline/src/web.ts b/client/capacitor/plugins/capacitor-plugin-outline/src/web.ts index 679283f5829..a18a0d06ff9 100644 --- a/client/capacitor/plugins/capacitor-plugin-outline/src/web.ts +++ b/client/capacitor/plugins/capacitor-plugin-outline/src/web.ts @@ -23,6 +23,10 @@ export class CapacitorPluginOutlineWeb extends WebPlugin implements CapacitorPluginOutline { + async isAndroidTv(): Promise<{isAndroidTv: boolean}> { + return {isAndroidTv: false}; + } + async invokeMethod(_options: { method: string; input: string; diff --git a/client/capacitor/src/index.ts b/client/capacitor/src/index.ts index b3a47c71b4d..b1de8ce177c 100644 --- a/client/capacitor/src/index.ts +++ b/client/capacitor/src/index.ts @@ -45,6 +45,20 @@ interface AsyncVpnApi extends VpnApi { const hasDeviceSupport = Capacitor.isNativePlatform(); +async function detectAndroidTvDevice(): Promise { + if (Capacitor.getPlatform() !== 'android') { + return false; + } + + try { + const result = await CapacitorPluginOutline.isAndroidTv(); + return result.isAndroidTv; + } catch (error) { + console.error('Failed to detect Android TV device', error); + return false; + } +} + class CapacitorClipboard extends AbstractClipboard { async getContents(): Promise { if (!navigator.clipboard?.readText) { @@ -135,6 +149,8 @@ class CapacitorVpnApi implements AsyncVpnApi { } class CapacitorPlatform implements OutlinePlatform { + constructor(readonly isAndroidTv: boolean) {} + getVpnApi(): AsyncVpnApi | undefined { return hasDeviceSupport ? new CapacitorVpnApi() : undefined; } @@ -239,20 +255,22 @@ installDefaultMethodChannel( ); wireExternalLinkHandling(); -// The migration has to finish first: main() builds the server repository from -// localStorage, so replaying the Cordova data afterwards would leave the user -// staring at an empty server list until the next restart. -// -// Keep the .catch BEFORE the .then. In this order a failed migration is handled -// here and main() still runs, so the app starts without the migrated data. The -// forms look interchangeable, but `.then(main).catch(...)` would skip main() -// entirely on a migration failure — an app that never launches, which is far -// worse than one that launches missing its servers. -migrateLegacyCordovaStorageIfNeeded() - .catch(e => { - console.error('Storage migration failed: ', e); - }) - .then(() => main(new CapacitorPlatform())) - .catch(e => { - console.error('main() failed: ', e); - }); +async function bootstrap() { + const isAndroidTv = await detectAndroidTvDevice(); + + // The migration has to finish first: main() builds the server repository from + // localStorage, so replaying the Cordova data afterwards would leave the user + // staring at an empty server list until the next restart. A failed migration + // must not prevent the app from launching. + try { + await migrateLegacyCordovaStorageIfNeeded(); + } catch (error) { + console.error('Storage migration failed: ', error); + } + + await main(new CapacitorPlatform(isAndroidTv)); +} + +bootstrap().catch(error => { + console.error('main() failed: ', error); +}); diff --git a/client/config.xml b/client/config.xml index 457215a0cba..53a81fdbba4 100644 --- a/client/config.xml +++ b/client/config.xml @@ -31,6 +31,15 @@ + + + + + + + + @@ -38,12 +47,39 @@ + + + + + + + + + + + + diff --git a/client/resources/icons/android/tv_banner.png b/client/resources/icons/android/tv_banner.png new file mode 100644 index 00000000000..1a69a3c6239 Binary files /dev/null and b/client/resources/icons/android/tv_banner.png differ diff --git a/client/src/cordova/android/README.md b/client/src/cordova/android/README.md index 437bac77190..3606c10fc64 100644 --- a/client/src/cordova/android/README.md +++ b/client/src/cordova/android/README.md @@ -91,6 +91,12 @@ To build the app for Android, run: The built apk will be at: `client/platforms/android/app/build/outputs/apk/debug/app-debug.apk`. +The same APK is used on phones, tablets and Android TV. It keeps the +`org.outline.android.client` application ID, advertises both the regular and +Leanback launchers, and detects Android TV at runtime to enable D-pad +navigation. Android chooses the device's natural orientation, so TV devices +remain landscape without forcing phones into landscape mode. + We also support passing a `--verbose` option on cordova android: ```sh diff --git a/client/src/cordova/plugin/android/java/org/outline/OutlinePlugin.java b/client/src/cordova/plugin/android/java/org/outline/OutlinePlugin.java index 2eedccfb9cd..de681a00727 100644 --- a/client/src/cordova/plugin/android/java/org/outline/OutlinePlugin.java +++ b/client/src/cordova/plugin/android/java/org/outline/OutlinePlugin.java @@ -15,6 +15,7 @@ package org.outline; import android.app.Activity; +import android.app.UiModeManager; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; import android.content.ComponentName; @@ -22,6 +23,8 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.content.res.Configuration; import android.net.VpnService; import android.os.IBinder; import android.os.RemoteException; @@ -62,6 +65,7 @@ public enum Action { STOP("stop"), ON_STATUS_CHANGE("onStatusChange"), IS_RUNNING("isRunning"), + IS_ANDROID_TV("isAndroidTv"), INIT_ERROR_REPORTING("initializeErrorReporting"), REPORT_EVENTS("reportEvents"), QUIT("quitApplication"); @@ -224,6 +228,9 @@ private void executeAsync( final String tunnelId = args.getString(0); boolean isActive = isTunnelActive(tunnelId); callback.sendPluginResult(new PluginResult(PluginResult.Status.OK, isActive)); + } else if (Action.IS_ANDROID_TV.is(action)) { + callback.sendPluginResult( + new PluginResult(PluginResult.Status.OK, isAndroidTv())); // Static actions } else if (Action.INIT_ERROR_REPORTING.is(action)) { @@ -248,6 +255,18 @@ private void executeAsync( }); } + // Returns whether the app is running on an Android TV or a device that + // advertises the Android TV Leanback feature. This is evaluated at runtime + // so phones, tablets and TVs can share the same APK and application ID. + private boolean isAndroidTv() { + Context context = getBaseContext(); + UiModeManager uiModeManager = + (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE); + return (uiModeManager != null + && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) + || context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); + } + // Requests user permission to connect the VPN. Returns true if permission was previously granted, // and false if the OS prompt will be displayed. private boolean prepareVpnService() throws ActivityNotFoundException { diff --git a/client/web/app/main.cordova.ts b/client/web/app/main.cordova.ts index 40486068091..f6d4164188d 100644 --- a/client/web/app/main.cordova.ts +++ b/client/web/app/main.cordova.ts @@ -38,6 +38,19 @@ import {SentryErrorReporter, Tags} from '../shared/error_reporter'; const hasDeviceSupport = cordova.platformId !== 'browser'; +async function detectAndroidTvDevice(): Promise { + if (cordova.platformId !== 'android') { + return false; + } + + try { + return await pluginExec('isAndroidTv'); + } catch (error) { + console.error('Failed to detect Android TV device', error); + return false; + } +} + // Pushes a clipboard event whenever the app is brought to the foreground. class CordovaClipboard extends AbstractClipboard { getContents() { @@ -87,6 +100,8 @@ class CordovaMethodChannel implements MethodChannel { // This class should only be instantiated after Cordova fires the deviceready event. class CordovaPlatform implements OutlinePlatform { + constructor(readonly isAndroidTv: boolean) {} + getVpnApi(): VpnApi | undefined { if (hasDeviceSupport) { return new CordovaVpnApi(); @@ -155,8 +170,9 @@ window.handleOpenURL = (url: string) => { // https://cordova.apache.org/docs/en/latest/cordova/events/events.html#deviceready document.addEventListener('deviceready', async () => { installDefaultMethodChannel(new CordovaMethodChannel()); + const isAndroidTv = await detectAndroidTvDevice(); try { - await main(new CordovaPlatform()); + await main(new CordovaPlatform(isAndroidTv)); } catch (e) { console.error('main() failed: ', e); } diff --git a/client/web/app/main.electron.ts b/client/web/app/main.electron.ts index 8a05158b499..7f9f575e85c 100644 --- a/client/web/app/main.electron.ts +++ b/client/web/app/main.electron.ts @@ -150,6 +150,7 @@ class ElectronMethodChannel implements MethodChannel { installDefaultMethodChannel(new ElectronMethodChannel()); main({ + isAndroidTv: false, getVpnApi(): VpnApi | undefined { if (isOsSupported) { return new ElectronVpnApi(); diff --git a/client/web/app/main.ts b/client/web/app/main.ts index f701d3ba3ae..7d8aa3b3b9c 100644 --- a/client/web/app/main.ts +++ b/client/web/app/main.ts @@ -49,8 +49,10 @@ const oncePolymerIsReady = new Promise(resolve => { // Helpers // Do not call until WebComponentsReady has fired! +type AppRootElement = polymer.Base & {isAndroidTv: boolean}; + function getRootEl() { - return document.querySelector('app-root') as {} as polymer.Base; + return document.querySelector('app-root') as unknown as AppRootElement; } async function createServerRepo( @@ -122,10 +124,14 @@ export function main(platform: OutlinePlatform) { const eventQueue = new EventQueue(); const serverRepo = await createServerRepo(platform, eventQueue); const settings = new Settings(); + const rootEl = getRootEl(); + // Native entry points resolve this before calling main(). Pass it through + // the platform instead of coordinating app-root with a global event. + rootEl.isAndroidTv = platform.isAndroidTv; new App( eventQueue, serverRepo, - getRootEl(), + rootEl, debugMode, platform.getUrlInterceptor(), platform.getClipboard(), diff --git a/client/web/app/platform.ts b/client/web/app/platform.ts index c5170679a76..749b757bcc2 100644 --- a/client/web/app/platform.ts +++ b/client/web/app/platform.ts @@ -23,6 +23,9 @@ import {OutlineErrorReporter} from '../shared/error_reporter'; // Provides platform-specific dependencies. // TODO(fortuna): pick platform-specific implementations at build time instead. export interface OutlinePlatform { + /** Whether the native host is an Android TV device. */ + readonly isAndroidTv: boolean; + getVpnApi(): VpnApi | undefined; getUrlInterceptor(): UrlInterceptor | undefined; diff --git a/client/web/ui_components/app-root.js b/client/web/ui_components/app-root.js index 81a96d0c0cc..d532682577d 100644 --- a/client/web/ui_components/app-root.js +++ b/client/web/ui_components/app-root.js @@ -72,6 +72,12 @@ import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js'; import {html} from '@polymer/polymer/lib/utils/html-tag.js'; import {PolymerElement} from '@polymer/polymer/polymer-element.js'; +// Keep this a static import: the module has no platform dependencies, so +// including it in non-TV bundles is simpler and avoiding an async loader +// removes a race during Polymer reconnect/disconnect. Installation is still +// guarded by the reactive isAndroidTv property. +import {installTvNavigation as installTvNavigationModule} from './tv-navigation.js'; + // Workaround: // https://github.com/PolymerElements/paper-menu-button/issues/101#issuecomment-297856912 PaperMenuButton.prototype.properties.restoreFocusOnClose.value = false; @@ -80,6 +86,14 @@ export class AppRoot extends mixinBehaviors( [AppLocalizeBehavior], PolymerElement ) { + /** + * Cleanup returned by TV navigation while this element is connected to an + * Android TV host. It is cleared when the host disconnects or the native + * platform reports a non-TV device. + * @type {(() => void) | undefined} + */ + removeTvNavigation; + static get template() { return html`