Add microphone foreground service to keep Android DM calls alive under lock screen
This commit is contained in:
parent
fab533e762
commit
e8188d7361
8 changed files with 343 additions and 1 deletions
|
|
@ -73,6 +73,11 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".CallForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="microphone" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".CallCancelReceiver"
|
android:name=".CallCancelReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
@ -96,4 +101,10 @@
|
||||||
FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we
|
FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we
|
||||||
never call setFullScreenIntent(). See ADR 2.5-heads-up. -->
|
never call setFullScreenIntent(). See ADR 2.5-heads-up. -->
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
<!-- DM call lock-screen retention: CallForegroundService keeps the call
|
||||||
|
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO
|
||||||
|
and netd doesn't block background network. Context in
|
||||||
|
docs/plans/dm_calls_techdebt.md §2.2. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS → Android bridge for CallForegroundService lifecycle.
|
||||||
|
*
|
||||||
|
* start / stop map onto startForegroundService / stopService.
|
||||||
|
*
|
||||||
|
* RECORD_AUDIO permission is re-verified here before dispatch: the JS caller
|
||||||
|
* (useAndroidCallForegroundSync) gates on the widget's JoinCall signal, which
|
||||||
|
* implies getUserMedia has run and the grant is in place — but the plugin
|
||||||
|
* checks defensively so the service never attempts startForeground with
|
||||||
|
* TYPE_MICROPHONE without the permission. The manifest declares the service
|
||||||
|
* as foregroundServiceType="microphone" only (no fallback type), so on API
|
||||||
|
* 34+ starting without RECORD_AUDIO would throw ForegroundServiceTypeException.
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "CallForegroundService")
|
||||||
|
public class CallForegroundPlugin extends Plugin {
|
||||||
|
|
||||||
|
private static final String TAG = "CallFgsPlugin";
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void start(PluginCall call) {
|
||||||
|
String title = call.getString("title");
|
||||||
|
String body = call.getString("body");
|
||||||
|
Context ctx = getContext();
|
||||||
|
|
||||||
|
// Defense-in-depth: starting the microphone-typed FGS without
|
||||||
|
// RECORD_AUDIO granted is invalid on API 34+. JS side already gates
|
||||||
|
// on JoinCall (see useAndroidCallForegroundSync) so this should never
|
||||||
|
// fire in practice. If it does, resolve cleanly without starting —
|
||||||
|
// the call will run without retention, which is the same fate as
|
||||||
|
// the first-ever-call window before getUserMedia prompt answered.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
int micPerm = ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO);
|
||||||
|
if (micPerm != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Log.w(TAG, "start: RECORD_AUDIO not granted, skipping FGS (would fail on TYPE_MICROPHONE)");
|
||||||
|
call.resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent intent = new Intent(ctx, CallForegroundService.class);
|
||||||
|
if (title != null) intent.putExtra(CallForegroundService.EXTRA_TITLE, title);
|
||||||
|
if (body != null) intent.putExtra(CallForegroundService.EXTRA_BODY, body);
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
ctx.startForegroundService(intent);
|
||||||
|
} else {
|
||||||
|
ctx.startService(intent);
|
||||||
|
}
|
||||||
|
Log.d(TAG, "start: service started");
|
||||||
|
call.resolve();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "start: failed to start service", t);
|
||||||
|
call.reject("start_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void stop(PluginCall call) {
|
||||||
|
Context ctx = getContext();
|
||||||
|
try {
|
||||||
|
ctx.stopService(new Intent(ctx, CallForegroundService.class));
|
||||||
|
Log.d(TAG, "stop: stopService dispatched");
|
||||||
|
call.resolve();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "stop: stopService threw", t);
|
||||||
|
call.reject("stop_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service kept alive for the duration of an active DM call. Its
|
||||||
|
* sole job is to promote the process to PROCESS_STATE_FOREGROUND_SERVICE so
|
||||||
|
* Android doesn't:
|
||||||
|
* - revoke RECORD_AUDIO via AppOps (API 31+ while-in-use gating);
|
||||||
|
* - apply background network firewall via netd.
|
||||||
|
*
|
||||||
|
* Both revocations were observed in Phase 0 capture on Samsung OneUI API 36:
|
||||||
|
* mic went Active=false ~5s after screen-off, netd isBlocked=true ~13s after,
|
||||||
|
* causing Element Call inside the hidden WebView to tear down the LiveKit
|
||||||
|
* session and the call to drop. Full context in docs/plans/dm_calls_techdebt.md §2.2.
|
||||||
|
*
|
||||||
|
* Preconditions enforced by callers:
|
||||||
|
* - RECORD_AUDIO runtime permission granted (plugin-side check in
|
||||||
|
* CallForegroundPlugin.start). The manifest declares
|
||||||
|
* foregroundServiceType="microphone" only, so TYPE_NONE is not a valid
|
||||||
|
* fallback on API 34+ — we never attempt one.
|
||||||
|
* - JS side gates on useCallJoined so the widget's getUserMedia has already
|
||||||
|
* prompted for and received the grant by the time we start.
|
||||||
|
*/
|
||||||
|
public class CallForegroundService extends Service {
|
||||||
|
|
||||||
|
public static final String EXTRA_TITLE = "title";
|
||||||
|
public static final String EXTRA_BODY = "body";
|
||||||
|
|
||||||
|
private static final String CHANNEL_ID = "vojo_calls_ongoing";
|
||||||
|
// Stable id, distinct from VojoFirebaseMessagingService.SUMMARY_NOTIFICATION_ID
|
||||||
|
// (Integer.MIN_VALUE) and from per-room call ids (String.hashCode of "call_<roomId>").
|
||||||
|
private static final int NOTIFICATION_ID = 0x766F6A6F; // "vojo" as ASCII bytes
|
||||||
|
|
||||||
|
private static final String TAG = "CallFgs";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null;
|
||||||
|
String body = intent != null ? intent.getStringExtra(EXTRA_BODY) : null;
|
||||||
|
if (title == null || title.isEmpty()) title = "Активный звонок";
|
||||||
|
if (body == null) body = "";
|
||||||
|
|
||||||
|
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null) {
|
||||||
|
Log.w(TAG, "onStartCommand: NotificationManager null, cannot start FGS");
|
||||||
|
stopSelf(startId);
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureOngoingChannel(nm);
|
||||||
|
|
||||||
|
Intent launchIntent = new Intent(this, MainActivity.class)
|
||||||
|
.setAction(Intent.ACTION_MAIN)
|
||||||
|
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||||
|
int piFlags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||||
|
PendingIntent launchPI = PendingIntent.getActivity(this, 0, launchIntent, piFlags);
|
||||||
|
|
||||||
|
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setContentIntent(launchPI);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
// API 30+: FOREGROUND_SERVICE_TYPE_MICROPHONE constant exists
|
||||||
|
// and 3-arg startForeground is available. API 34+ REQUIRES
|
||||||
|
// the type to match the manifest — we declared `microphone`
|
||||||
|
// and always pass it. RECORD_AUDIO grant is ensured by
|
||||||
|
// CallForegroundPlugin before this code runs.
|
||||||
|
startForeground(NOTIFICATION_ID, builder.build(),
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
|
||||||
|
Log.d(TAG, "startForeground ok type=microphone");
|
||||||
|
} else {
|
||||||
|
// API 24-29: 2-arg form; manifest foregroundServiceType attribute
|
||||||
|
// is enough for the OS to classify the service correctly.
|
||||||
|
startForeground(NOTIFICATION_ID, builder.build());
|
||||||
|
Log.d(TAG, "startForeground ok (pre-R, manifest-driven type)");
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// If startForeground with TYPE_MICROPHONE throws despite our
|
||||||
|
// precondition checks (unexpected OEM behavior, race, manifest
|
||||||
|
// drift), we intentionally do NOT retry with TYPE_NONE — that is
|
||||||
|
// invalid on API 34+ when the manifest declares `microphone`.
|
||||||
|
// Better to surface the failure and let the call proceed without
|
||||||
|
// retention than to silently crash with ForegroundServiceTypeException.
|
||||||
|
Log.e(TAG, "startForeground threw, stopping service without retry", t);
|
||||||
|
stopSelf(startId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
Log.d(TAG, "onDestroy");
|
||||||
|
// Belt-and-suspenders: if the service is being stopped via stopService
|
||||||
|
// and the FGS flag is still up, make sure the notification goes away.
|
||||||
|
// Idempotent if stopForeground was already called elsewhere.
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureOngoingChannel(NotificationManager nm) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
|
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Активные звонки",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
);
|
||||||
|
channel.setDescription("Уведомление во время активного звонка Vojo");
|
||||||
|
channel.setShowBadge(false);
|
||||||
|
channel.enableLights(false);
|
||||||
|
channel.enableVibration(false);
|
||||||
|
channel.setSound(null, null);
|
||||||
|
nm.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ public class MainActivity extends BridgeActivity {
|
||||||
// can wire them into the WebView bridge on load. Registering after
|
// can wire them into the WebView bridge on load. Registering after
|
||||||
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
||||||
registerPlugin(FullScreenIntentPlugin.class);
|
registerPlugin(FullScreenIntentPlugin.class);
|
||||||
|
registerPlugin(CallForegroundPlugin.class);
|
||||||
EdgeToEdge.enable(this);
|
EdgeToEdge.enable(this);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { ReactNode, useCallback, useRef } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
import {
|
import {
|
||||||
CallEmbedContextProvider,
|
CallEmbedContextProvider,
|
||||||
|
|
@ -12,6 +12,7 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
|
import { useAndroidCallForegroundSync } from '../hooks/useAndroidCallForegroundSync';
|
||||||
|
|
||||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
@ -27,6 +28,17 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||||
}, [store, embed, setCallEmbed])
|
}, [store, embed, setCallEmbed])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Widget failed to prepare (bad network, iframe load error). Without this,
|
||||||
|
// the embed lingers with joined=false forever, and the Android FGS (keyed
|
||||||
|
// to callEmbedAtom presence) stays up as a ghost ongoing notification.
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = embed.onPreparingError(() => {
|
||||||
|
if (store.get(callEmbedAtom) !== embed) return;
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [embed, store, setCallEmbed]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,6 +50,8 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||||
const callEmbedRef = useRef<HTMLDivElement>(null);
|
const callEmbedRef = useRef<HTMLDivElement>(null);
|
||||||
const joined = useCallJoined(callEmbed);
|
const joined = useCallJoined(callEmbed);
|
||||||
|
|
||||||
|
useAndroidCallForegroundSync();
|
||||||
|
|
||||||
const selectedRoom = useSelectedRoom();
|
const selectedRoom = useSelectedRoom();
|
||||||
const chat = useAtomValue(callChatAtom);
|
const chat = useAtomValue(callChatAtom);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
|
||||||
53
src/app/hooks/useAndroidCallForegroundSync.ts
Normal file
53
src/app/hooks/useAndroidCallForegroundSync.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// Keep an Android foreground service alive while a DM call is actively joined.
|
||||||
|
//
|
||||||
|
// Lifecycle is keyed to the widget's JoinCall signal (via useCallJoined), not
|
||||||
|
// the mere presence of callEmbedAtom. Rationale:
|
||||||
|
//
|
||||||
|
// 1. RECORD_AUDIO runtime grant. By the time JoinCall fires, Element Call
|
||||||
|
// inside the iframe has already called getUserMedia, which prompted the
|
||||||
|
// OS permission dialog (if needed) and user granted. Starting the FGS
|
||||||
|
// earlier (during `preparing`, before getUserMedia) means RECORD_AUDIO
|
||||||
|
// may not be granted yet — and on API 34+ startForeground with
|
||||||
|
// TYPE_MICROPHONE throws SecurityException without it, while a
|
||||||
|
// fallback to TYPE_NONE is not a valid subset of the manifest-declared
|
||||||
|
// `microphone` service type. Gating on joined sidesteps the whole
|
||||||
|
// fallback question.
|
||||||
|
//
|
||||||
|
// 2. No ghost notification during preparingError. If the widget fails to
|
||||||
|
// load, JoinCall never fires, the service never starts, nothing to
|
||||||
|
// clean up.
|
||||||
|
//
|
||||||
|
// Tradeoff: a tiny (1-3s) window between embed creation and JoinCall has no
|
||||||
|
// retention. The call isn't actually live in that window — media hasn't
|
||||||
|
// started — so lock-screen there is a non-event; user can retry.
|
||||||
|
//
|
||||||
|
// Android-only. Context: docs/plans/dm_calls_techdebt.md §2.2.
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { callForegroundService } from '../plugins/call/callForegroundService';
|
||||||
|
import { useCallJoined } from './useCallEmbed';
|
||||||
|
import { isAndroidPlatform } from '../utils/capacitor';
|
||||||
|
|
||||||
|
export const useAndroidCallForegroundSync = (): void => {
|
||||||
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
const joined = useCallJoined(callEmbed);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAndroidPlatform()) return undefined;
|
||||||
|
if (!joined) return undefined;
|
||||||
|
|
||||||
|
callForegroundService.start({ title: 'Активный звонок' }).catch((err: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[call-fgs] start failed', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
callForegroundService.stop().catch((err: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[call-fgs] stop failed', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [joined]);
|
||||||
|
};
|
||||||
33
src/app/plugins/call/callForegroundService.ts
Normal file
33
src/app/plugins/call/callForegroundService.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Typed wrapper around the CallForegroundService Capacitor plugin.
|
||||||
|
//
|
||||||
|
// The native service (CallForegroundService.java + CallForegroundPlugin.java)
|
||||||
|
// holds an Android foreground service with foregroundServiceType="microphone"
|
||||||
|
// for the duration of an active DM call. Without it, Android 14+ revokes the
|
||||||
|
// mic AppOp and applies background data firewall when the app goes to
|
||||||
|
// background (e.g. screen lock), killing the call.
|
||||||
|
//
|
||||||
|
// Context: docs/plans/dm_calls_techdebt.md §2.2.
|
||||||
|
|
||||||
|
import { registerPlugin } from '@capacitor/core';
|
||||||
|
import { isAndroidPlatform } from '../../utils/capacitor';
|
||||||
|
|
||||||
|
interface CallForegroundServicePlugin {
|
||||||
|
start(options?: { title?: string; body?: string }): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundService');
|
||||||
|
|
||||||
|
// Android-only — the native implementation lives in CallForegroundService.java
|
||||||
|
// and CallForegroundPlugin.java. Gating on platform here (not just isNativePlatform)
|
||||||
|
// avoids calling a non-existent plugin path on iOS or any future native target.
|
||||||
|
export const callForegroundService = {
|
||||||
|
start(options?: { title?: string; body?: string }): Promise<void> {
|
||||||
|
if (!isAndroidPlatform()) return Promise.resolve();
|
||||||
|
return plugin.start(options);
|
||||||
|
},
|
||||||
|
stop(): Promise<void> {
|
||||||
|
if (!isAndroidPlatform()) return Promise.resolve();
|
||||||
|
return plugin.stop();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,8 @@ import { Browser } from '@capacitor/browser';
|
||||||
|
|
||||||
export const isNativePlatform = (): boolean => Capacitor.isNativePlatform();
|
export const isNativePlatform = (): boolean => Capacitor.isNativePlatform();
|
||||||
|
|
||||||
|
export const isAndroidPlatform = (): boolean => Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
export const openExternalUrl = async (url: string): Promise<void> => {
|
export const openExternalUrl = async (url: string): Promise<void> => {
|
||||||
if (isNativePlatform()) {
|
if (isNativePlatform()) {
|
||||||
await Browser.open({ url });
|
await Browser.open({ url });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue