Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/capacitor/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ android {
versionCode 1
versionName "1.0"
}

buildTypes {
release {
minifyEnabled false
Expand Down
9 changes: 9 additions & 0 deletions client/capacitor/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
-->

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />

<application
android:allowBackup="false"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
Expand All @@ -27,6 +31,7 @@
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:banner="@drawable/tv_banner"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleInstance"
Expand All @@ -36,6 +41,10 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import type {PluginListenerHandle} from '@capacitor/core';

export interface CapacitorPluginOutline {
isAndroidTv(): Promise<{isAndroidTv: boolean}>;
invokeMethod(options: {
method: string;
input: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export class CapacitorPluginOutlineWeb
extends WebPlugin
implements CapacitorPluginOutline
{
async isAndroidTv(): Promise<{isAndroidTv: boolean}> {
Comment thread
vad612 marked this conversation as resolved.
return {isAndroidTv: false};
}

async invokeMethod(_options: {
method: string;
input: string;
Expand Down
25 changes: 24 additions & 1 deletion client/capacitor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ interface AsyncVpnApi extends VpnApi {
}

const hasDeviceSupport = Capacitor.isNativePlatform();
const ANDROID_TV_DEVICE_READY_EVENT = 'outline-android-tv-device-ready';

declare global {
Comment thread
vad612 marked this conversation as resolved.
Outdated
interface Window {
outlineTvDevice?: boolean;
}
}

async function detectAndroidTvDevice() {
if (Capacitor.getPlatform() !== 'android') {
return;
}

try {
const result = await CapacitorPluginOutline.isAndroidTv();
window.outlineTvDevice = result.isAndroidTv;
} catch (error) {
console.error('Failed to detect Android TV device', error);
window.outlineTvDevice = false;
}
document.dispatchEvent(new Event(ANDROID_TV_DEVICE_READY_EVENT));
}

class CapacitorClipboard extends AbstractClipboard {
async getContents(): Promise<string> {
Expand Down Expand Up @@ -248,7 +270,8 @@ wireExternalLinkHandling();
// 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()
detectAndroidTvDevice()
.then(() => migrateLegacyCordovaStorageIfNeeded())
.catch(e => {
console.error('Storage migration failed: ', e);
})
Expand Down
36 changes: 36 additions & 0 deletions client/config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,55 @@

<!-- Android -->
<platform name="android">
<!-- Let Android choose the natural orientation for the device. The web
UI enables D-pad navigation at runtime on Android TV devices. -->
<preference name="Orientation" value="unspecified" />
<!-- Keep one package compatible with phones, tablets and Android TV. -->
<config-file parent="/manifest" target="AndroidManifest.xml">
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
</config-file>

<icon density="ldpi" src="resources/icons/android/icon_mdpi.png" />
<icon density="mdpi" src="resources/icons/android/icon_mdpi.png" />
<icon density="hdpi" src="resources/icons/android/icon_hdpi.png" />
<icon density="xhdpi" src="resources/icons/android/icon_xhdpi.png" />
<icon density="xxhdpi" src="resources/icons/android/icon_xxhdpi.png" />
<icon density="xxxhdpi" src="resources/icons/android/icon_xxxhdpi.png" />

<resource-file
src="resources/icons/android/tv_banner.png"
target="app/src/main/res/drawable-xhdpi/tv_banner.png"
/>
<edit-config
file="AndroidManifest.xml"
mode="merge"
target="/manifest/application"
>
<application
android:banner="@drawable/tv_banner"
/>
</edit-config>
<edit-config
file="AndroidManifest.xml"
mode="merge"
target="/manifest/application/activity[@android:name='MainActivity']"
>
<activity
android:banner="@drawable/tv_banner"
/>
</edit-config>

<preference name="AndroidWindowSplashScreenAnimatedIcon" value="resources/splashscreen/android/splash_screen_icon_xxxhdpi.png" />
<preference name="AndroidWindowSplashScreenBackground" value="#263237" />

<preference name="AndroidInsecureFileModeEnabled" value="true" />

<config-file parent="./application/activity/[@android:name='MainActivity']" target="AndroidManifest.xml">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
Expand Down
Binary file added client/resources/icons/android/tv_banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions client/src/cordova/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
package org.outline;

import android.app.Activity;
import android.app.UiModeManager;
import android.content.ActivityNotFoundException;
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.IBinder;
import android.os.RemoteException;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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)) {
Expand All @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions client/web/app/main.cordova.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,28 @@ import * as interceptors from './url_interceptor';
import {NoOpVpnInstaller, VpnInstaller} from './vpn_installer';
import {SentryErrorReporter, Tags} from '../shared/error_reporter';

declare global {
interface Window {
outlineTvDevice?: boolean;
}
}

const hasDeviceSupport = cordova.platformId !== 'browser';
const ANDROID_TV_DEVICE_READY_EVENT = 'outline-android-tv-device-ready';

async function detectAndroidTvDevice() {
if (cordova.platformId !== 'android') {
return;
}

try {
window.outlineTvDevice = await pluginExec<boolean>('isAndroidTv');
} catch (error) {
console.error('Failed to detect Android TV device', error);
window.outlineTvDevice = false;
}
document.dispatchEvent(new Event(ANDROID_TV_DEVICE_READY_EVENT));
}

// Pushes a clipboard event whenever the app is brought to the foreground.
class CordovaClipboard extends AbstractClipboard {
Expand Down Expand Up @@ -155,6 +176,7 @@ window.handleOpenURL = (url: string) => {
// https://cordova.apache.org/docs/en/latest/cordova/events/events.html#deviceready
document.addEventListener('deviceready', async () => {
installDefaultMethodChannel(new CordovaMethodChannel());
await detectAndroidTvDevice();
try {
await main(new CordovaPlatform());
} catch (e) {
Expand Down
59 changes: 57 additions & 2 deletions client/web/ui_components/app-root.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export class AppRoot extends mixinBehaviors(
[AppLocalizeBehavior],
PolymerElement
) {
constructor() {
super();
this.handleTvDeviceReady = this.handleTvDeviceReady.bind(this);
Comment thread
vad612 marked this conversation as resolved.
Outdated
}

static get template() {
return html`
<style>
Expand Down Expand Up @@ -649,12 +654,62 @@ export class AppRoot extends mixinBehaviors(
}

if (typeof cordova === 'undefined') {
// If cordova is not defined, we're running in Electron.
this.platform = 'Electron';
// Capacitor exposes its platform on the shared global. Keep the shell
// host-agnostic so Android TV can enable navigation in either host.
this.platform = globalThis.Capacitor?.getPlatform?.() || 'Electron';
} else {
// Don't use cordova?.platformId, ReferenceError will be thrown
this.platform = globalThis.cordova.platformId;
}

this.installTvNavigationIfNeeded();
}

connectedCallback() {
super.connectedCallback();
globalThis.document.addEventListener(
'outline-android-tv-device-ready',
Comment thread
vad612 marked this conversation as resolved.
Outdated
this.handleTvDeviceReady
);
this.installTvNavigationIfNeeded();
}

handleTvDeviceReady() {
Comment thread
vad612 marked this conversation as resolved.
Outdated
this.installTvNavigationIfNeeded();
}

installTvNavigationIfNeeded() {
if (
Comment thread
vad612 marked this conversation as resolved.
!globalThis.window.outlineTvDevice ||
this.tvNavigationLoad ||
Comment thread
vad612 marked this conversation as resolved.
Outdated
this.removeTvNavigation
) {
return;
}

const navigationLoad = import('./tv-navigation.js');
this.tvNavigationLoad = navigationLoad;
navigationLoad
.then(({installTvNavigation}) => {
Comment thread
vad612 marked this conversation as resolved.
Outdated
if (this.tvNavigationLoad !== navigationLoad || !this.isConnected) {
return;
}
this.removeTvNavigation = installTvNavigation(this.shadowRoot);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does installTvNavigation do?

Can't we always install it? Please explain the tradeoff.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It installs D-pad, activation, Back, and focus handlers and returns a cleanup function. Enabling those handlers on phones or desktop would interfere with normal keyboard navigation, so installation is limited to Android TV

})
.catch(error => {
console.error('Failed to install Android TV navigation', error);
});
}

disconnectedCallback() {
globalThis.document.removeEventListener(
'outline-android-tv-device-ready',
Comment thread
vad612 marked this conversation as resolved.
Outdated
this.handleTvDeviceReady
);
this.tvNavigationLoad = undefined;
this.removeTvNavigation?.();
this.removeTvNavigation = undefined;
super.disconnectedCallback();
}

setLanguage(languageCode) {
Expand Down
Loading
Loading