Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
52 changes: 35 additions & 17 deletions client/capacitor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ interface AsyncVpnApi extends VpnApi {

const hasDeviceSupport = Capacitor.isNativePlatform();

async function detectAndroidTvDevice(): Promise<boolean> {
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<string> {
if (!navigator.clipboard?.readText) {
Expand Down Expand Up @@ -135,6 +149,8 @@ class CapacitorVpnApi implements AsyncVpnApi {
}

class CapacitorPlatform implements OutlinePlatform {
constructor(readonly isAndroidTv: boolean) {}

getVpnApi(): AsyncVpnApi | undefined {
return hasDeviceSupport ? new CapacitorVpnApi() : undefined;
}
Expand Down Expand Up @@ -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);
});
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
18 changes: 17 additions & 1 deletion client/web/app/main.cordova.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ import {SentryErrorReporter, Tags} from '../shared/error_reporter';

const hasDeviceSupport = cordova.platformId !== 'browser';

async function detectAndroidTvDevice(): Promise<boolean> {
if (cordova.platformId !== 'android') {
return false;
}

try {
return await pluginExec<boolean>('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() {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions client/web/app/main.electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ class ElectronMethodChannel implements MethodChannel {
installDefaultMethodChannel(new ElectronMethodChannel());

main({
isAndroidTv: false,
getVpnApi(): VpnApi | undefined {
if (isOsSupported) {
return new ElectronVpnApi();
Expand Down
10 changes: 8 additions & 2 deletions client/web/app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ const oncePolymerIsReady = new Promise<void>(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(
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading