Skip to content
Closed
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
18 changes: 18 additions & 0 deletions client/capacitor/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

apply plugin: 'com.android.application'

def tvBannerSource = file("${rootProject.projectDir}/../../resources/icons/android/tv_banner.png")
def tvBannerResources = file("${buildDir}/generated/res/tv")

android {
namespace "org.outline.client"
compileSdk 36
Expand All @@ -32,6 +35,10 @@ android {
versionCode 1
versionName "1.0"
}

sourceSets {
main.res.srcDir(tvBannerResources)
}

buildTypes {
release {
Expand All @@ -46,6 +53,17 @@ android {
}
}

def copyTvBanner = tasks.register('copyTvBanner', Sync) {
from(tvBannerSource)
into(new File(tvBannerResources, 'drawable-xhdpi'))
}

tasks.configureEach { task ->
if (task.name.startsWith('merge') && task.name.endsWith('Resources')) {
task.dependsOn(copyTvBanner)
}
}

dependencies {
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:$rootProject.ext.androidxAppCompatVersion"
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);
}
}
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
4 changes: 4 additions & 0 deletions client/capacitor/plugins/capacitor-plugin-outline/src/web.ts
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}> {
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 {
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
17 changes: 12 additions & 5 deletions client/web/build.action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import {runWebpack} from '../build/run_webpack.mjs';
* @param {string[]} parameters
*/
export async function main(...parameters) {
const {sentryDsn, platform, buildMode, versionName, buildNumber} = getBuildParameters(parameters);
const {sentryDsn, platform, buildMode, versionName, buildNumber} =
getBuildParameters(parameters);

await rmfr(path.resolve(getRootDir(), 'www'));

Expand All @@ -38,11 +39,15 @@ export async function main(...parameters) {

if (buildMode === 'release') {
if (versionName === '0.0.0') {
throw new TypeError('Release builds require a valid versionName, but it is set to 0.0.0.');
throw new TypeError(
'Release builds require a valid versionName, but it is set to 0.0.0.'
);
}

if (!sentryDsn) {
throw new TypeError('Release builds require SENTRY_DSN, but it is not defined.');
throw new TypeError(
'Release builds require SENTRY_DSN, but it is not defined.'
);
}

/*
Expand All @@ -51,12 +56,14 @@ export async function main(...parameters) {
*/
try {
new URL(sentryDsn);
} catch (e) {
} catch {
throw new TypeError(`The sentryDsn ${sentryDsn} is not a valid URL!`);
}
}

await fs.mkdir(path.resolve(getRootDir(), 'client', 'www'), {recursive: true});
await fs.mkdir(path.resolve(getRootDir(), 'client', 'www'), {
recursive: true,
});

await fs.writeFile(
path.resolve(getRootDir(), 'client', 'www', 'environment.json'),
Expand Down
Loading
Loading