blob: 62165b25506894bd2391b5d4694d2526a51cbfc1 [file] [log] [blame]
// Copyright 2017 The Cobalt Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package dev.cobalt.coat;
import static android.content.Context.AUDIO_SERVICE;
import static android.media.AudioManager.GET_DEVICES_INPUTS;
import static dev.cobalt.util.Log.TAG;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.hardware.input.InputManager;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.os.Build;
import android.os.Build.VERSION;
import android.util.Pair;
import android.util.Size;
import android.util.SizeF;
import android.view.Display;
import android.view.InputDevice;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.CaptioningManager;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import dev.cobalt.account.UserAuthorizer;
import dev.cobalt.libraries.services.clientloginfo.ClientLogInfo;
import dev.cobalt.media.AudioOutputManager;
import dev.cobalt.media.CaptionSettings;
import dev.cobalt.media.CobaltMediaSession;
import dev.cobalt.media.MediaImage;
import dev.cobalt.util.DisplayUtil;
import dev.cobalt.util.Holder;
import dev.cobalt.util.Log;
import dev.cobalt.util.UsedByNative;
import java.lang.reflect.Method;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.Calendar;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.TimeZone;
/** Implementation of the required JNI methods called by the Starboard C++ code. */
public class StarboardBridge {
/** Interface to be implemented by the Android Application hosting the starboard app. */
public interface HostApplication {
void setStarboardBridge(StarboardBridge starboardBridge);
StarboardBridge getStarboardBridge();
}
private CobaltSystemConfigChangeReceiver sysConfigChangeReceiver;
private CobaltTextToSpeechHelper ttsHelper;
private UserAuthorizer userAuthorizer;
private AudioOutputManager audioOutputManager;
private CobaltMediaSession cobaltMediaSession;
private AudioPermissionRequester audioPermissionRequester;
private KeyboardEditor keyboardEditor;
private NetworkStatus networkStatus;
private ResourceOverlay resourceOverlay;
static {
// Even though NativeActivity already loads our library from C++,
// we still have to load it from Java to make JNI calls into it.
System.loadLibrary("coat");
}
private final Context appContext;
private final Holder<Activity> activityHolder;
private final Holder<Service> serviceHolder;
private final String[] args;
private final String startDeepLink;
private final Runnable stopRequester =
new Runnable() {
@Override
public void run() {
requestStop(0);
}
};
private volatile boolean starboardStopped = false;
private final HashMap<String, CobaltService.Factory> cobaltServiceFactories = new HashMap<>();
private final HashMap<String, CobaltService> cobaltServices = new HashMap<>();
private static final TimeZone DEFAULT_TIME_ZONE = TimeZone.getTimeZone("America/Los_Angeles");
private final long timeNanosecondsPerMicrosecond = 1000;
private Set<Integer> supportedHdrTypesSet = new HashSet<Integer>();
private long supportedHdrTypesSetUpdatedAt = 0;
private final long supportedHdrTypesCacheTtlMs = 1000;
public StarboardBridge(
Context appContext,
Holder<Activity> activityHolder,
Holder<Service> serviceHolder,
UserAuthorizer userAuthorizer,
String[] args,
String startDeepLink) {
// Make sure the JNI stack is properly initialized first as there is
// race condition as soon as any of the following objects creates a new thread.
nativeInitialize();
this.appContext = appContext;
this.activityHolder = activityHolder;
this.serviceHolder = serviceHolder;
this.args = args;
this.startDeepLink = startDeepLink;
this.sysConfigChangeReceiver = new CobaltSystemConfigChangeReceiver(appContext, stopRequester);
this.ttsHelper = new CobaltTextToSpeechHelper(appContext);
this.userAuthorizer = userAuthorizer;
this.audioOutputManager = new AudioOutputManager(appContext);
this.cobaltMediaSession =
new CobaltMediaSession(appContext, activityHolder, audioOutputManager);
this.audioPermissionRequester = new AudioPermissionRequester(appContext, activityHolder);
this.networkStatus = new NetworkStatus(appContext);
this.resourceOverlay = new ResourceOverlay(appContext);
}
private native boolean nativeInitialize();
private native long nativeSbTimeGetMonotonicNow();
protected void onActivityStart(Activity activity, KeyboardEditor keyboardEditor) {
activityHolder.set(activity);
this.keyboardEditor = keyboardEditor;
sysConfigChangeReceiver.setForeground(true);
// TODO: v0_1231sd2 is the default value used for testing,
// delete it once we verify it can be queried in QOE system.
if (!isReleaseBuild()) {
ClientLogInfo.setClientInfo("v0_1231sd2");
}
}
protected void onActivityStop(Activity activity) {
if (activityHolder.get() == activity) {
activityHolder.set(null);
}
sysConfigChangeReceiver.setForeground(false);
}
protected void onActivityDestroy(Activity activity) {
if (starboardStopped) {
// We can't restart the starboard app, so kill the process for a clean start next time.
Log.i(TAG, "Activity destroyed after shutdown; killing app.");
System.exit(0);
} else {
Log.i(TAG, "Activity destroyed without shutdown; app suspended in background.");
}
}
protected void onServiceStart(Service service) {
serviceHolder.set(service);
}
protected void onServiceDestroy(Service service) {
if (serviceHolder.get() == service) {
serviceHolder.set(null);
}
}
@SuppressWarnings("unused")
@UsedByNative
protected void startMediaPlaybackService() {
if (cobaltMediaSession == null || !cobaltMediaSession.isActive()) {
Log.w(TAG, "Do not start a MediaPlaybackService when the MediSsession is null or inactive.");
return;
}
Service service = serviceHolder.get();
if (service == null) {
if (appContext == null) {
Log.w(TAG, "Activiy already destroyed.");
return;
}
Log.i(TAG, "Cold start - Instantiating a MediaPlaybackService.");
Intent intent = new Intent(appContext, MediaPlaybackService.class);
try {
if (VERSION.SDK_INT >= 26) {
appContext.startForegroundService(intent);
} else {
appContext.startService(intent);
}
} catch (SecurityException e) {
Log.e(TAG, "Failed to start MediaPlaybackService with intent.", e);
return;
}
} else {
Log.i(TAG, "Warm start - Restarting the MediaPlaybackService.");
((MediaPlaybackService) service).startService();
}
}
@SuppressWarnings("unused")
@UsedByNative
protected void stopMediaPlaybackService() {
Service service = serviceHolder.get();
if (service != null) {
Log.i(TAG, "Stopping the MediaPlaybackService.");
((MediaPlaybackService) service).stopService();
}
}
@SuppressWarnings("unused")
@UsedByNative
protected void beforeStartOrResume() {
Log.i(TAG, "Prepare to resume");
// Bring our platform services to life before resuming so that they're ready to deal with
// whatever the web app wants to do with them as part of its start/resume logic.
cobaltMediaSession.resume();
networkStatus.beforeStartOrResume();
for (CobaltService service : cobaltServices.values()) {
service.beforeStartOrResume();
}
}
@SuppressWarnings("unused")
@UsedByNative
protected void beforeSuspend() {
try {
Log.i(TAG, "Prepare to suspend");
// We want the MediaSession to be deactivated immediately before suspending so that by the
// time, the launcher is visible our "Now Playing" card is already gone. Then Cobalt and
// the web app can take their time suspending after that.
cobaltMediaSession.suspend();
networkStatus.beforeSuspend();
for (CobaltService service : cobaltServices.values()) {
service.beforeSuspend();
}
// We need to stop MediaPlaybackService before suspending so that this foreground service
// would not prevent releasing activity's memory consumption.
stopMediaPlaybackService();
} catch (Throwable e) {
Log.i(TAG, "Caught exception in beforeSuspend: " + e.getMessage());
}
}
@SuppressWarnings("unused")
@UsedByNative
protected void afterStopped() {
starboardStopped = true;
ttsHelper.shutdown();
userAuthorizer.shutdown();
for (CobaltService service : cobaltServices.values()) {
service.afterStopped();
}
Activity activity = activityHolder.get();
if (activity != null) {
// Wait until the activity is destroyed to exit.
Log.i(TAG, "Shutdown in foreground; finishing Activity and removing task.");
activity.finishAndRemoveTask();
} else {
// We can't restart the starboard app, so kill the process for a clean start next time.
Log.i(TAG, "Shutdown in background; killing app without removing task.");
System.exit(0);
}
}
@SuppressWarnings("unused")
@UsedByNative
public void requestStop(int errorLevel) {
if (!starboardStopped) {
Log.i(TAG, "Request to stop");
nativeStopApp(errorLevel);
}
}
private native void nativeStopApp(int errorLevel);
@SuppressWarnings("unused")
@UsedByNative
public void requestSuspend() {
Activity activity = activityHolder.get();
if (activity != null) {
Log.i(TAG, "Request to suspend");
activity.finish();
}
}
public boolean onSearchRequested() {
return nativeOnSearchRequested();
}
private native boolean nativeOnSearchRequested();
@SuppressWarnings("unused")
@UsedByNative
public Context getApplicationContext() {
return appContext;
}
@SuppressWarnings("unused")
@UsedByNative
void raisePlatformError(@PlatformError.ErrorType int errorType, long data) {
PlatformError error = new PlatformError(activityHolder, errorType, data);
error.raise();
}
/** Returns true if the native code is compiled for release (i.e. 'gold' build). */
public static boolean isReleaseBuild() {
return nativeIsReleaseBuild();
}
private static native boolean nativeIsReleaseBuild();
protected Holder<Activity> getActivityHolder() {
return activityHolder;
}
@SuppressWarnings("unused")
@UsedByNative
protected String[] getArgs() {
return args;
}
/** Returns the URL from the Intent that started the app. */
@SuppressWarnings("unused")
@UsedByNative
protected String getStartDeepLink() {
return startDeepLink;
}
/** Sends an event to the web app to navigate to the given URL */
public void handleDeepLink(String url) {
nativeHandleDeepLink(url);
}
private native void nativeHandleDeepLink(String url);
/**
* Returns the absolute path to the directory where application specific files should be written.
* May be overridden for use cases that need to segregate storage.
*/
@SuppressWarnings("unused")
@UsedByNative
protected String getFilesAbsolutePath() {
return appContext.getFilesDir().getAbsolutePath();
}
/**
* Returns the absolute path to the application specific cache directory on the filesystem. May be
* overridden for use cases that need to segregate storage.
*/
@SuppressWarnings("unused")
@UsedByNative
protected String getCacheAbsolutePath() {
return appContext.getCacheDir().getAbsolutePath();
}
/**
* Returns non-loopback network interface address and its netmask, or null if none.
*
* <p>A Java function to help implement Starboard's SbSocketGetLocalInterfaceAddress.
*/
@SuppressWarnings("unused")
@UsedByNative
Pair<byte[], byte[]> getLocalInterfaceAddressAndNetmask(boolean wantIPv6) {
try {
Enumeration<NetworkInterface> it = NetworkInterface.getNetworkInterfaces();
while (it.hasMoreElements()) {
NetworkInterface ni = it.nextElement();
if (ni.isLoopback()) {
continue;
}
if (!ni.isUp()) {
continue;
}
if (ni.isPointToPoint()) {
continue;
}
for (InterfaceAddress ia : ni.getInterfaceAddresses()) {
byte[] address = ia.getAddress().getAddress();
boolean isIPv6 = (address.length > 4);
if (isIPv6 == wantIPv6) {
// Convert the network prefix length to a network mask.
int prefix = ia.getNetworkPrefixLength();
byte[] netmask = new byte[address.length];
for (int i = 0; i < netmask.length; i++) {
if (prefix == 0) {
netmask[i] = 0;
} else if (prefix >= 8) {
netmask[i] = (byte) 0xFF;
prefix -= 8;
} else {
netmask[i] = (byte) (0xFF << (8 - prefix));
prefix = 0;
}
}
return new Pair<>(address, netmask);
}
}
}
} catch (SocketException ex) {
// TODO should we have a logging story that strips logs for production?
Log.w(TAG, "sbSocketGetLocalInterfaceAddress exception", ex);
return null;
}
return null;
}
@SuppressWarnings("unused")
@UsedByNative
CobaltTextToSpeechHelper getTextToSpeechHelper() {
return ttsHelper;
}
/**
* @return A new CaptionSettings object with the current system caption settings.
*/
@SuppressWarnings("unused")
@UsedByNative
CaptionSettings getCaptionSettings() {
CaptioningManager cm =
(CaptioningManager) appContext.getSystemService(Context.CAPTIONING_SERVICE);
return new CaptionSettings(cm);
}
/** Java-layer implementation of SbSystemGetLocaleId. */
@SuppressWarnings("unused")
@UsedByNative
String systemGetLocaleId() {
return Locale.getDefault().toLanguageTag();
}
@SuppressWarnings("unused")
@UsedByNative
String getTimeZoneId() {
Locale locale = Locale.getDefault();
Calendar calendar = Calendar.getInstance(locale);
TimeZone timeZone = DEFAULT_TIME_ZONE;
if (calendar != null) {
timeZone = calendar.getTimeZone();
}
return timeZone.getID();
}
@SuppressWarnings("unused")
@UsedByNative
SizeF getDisplayDpi() {
return DisplayUtil.getDisplayDpi();
}
@SuppressWarnings("unused")
@UsedByNative
Size getDisplaySize() {
return DisplayUtil.getSystemDisplaySize();
}
@SuppressWarnings("unused")
@UsedByNative
public ResourceOverlay getResourceOverlay() {
return resourceOverlay;
}
@Nullable
private static String getSystemProperty(String name) {
try {
@SuppressLint("PrivateApi")
Class<?> systemProperties = Class.forName("android.os.SystemProperties");
Method getMethod = systemProperties.getMethod("get", String.class);
return (String) getMethod.invoke(systemProperties, name);
} catch (Exception e) {
Log.e(TAG, "Failed to read system property " + name, e);
return null;
}
}
@SuppressWarnings("unused")
@UsedByNative
Size getDeviceResolution() {
String displaySize =
android.os.Build.VERSION.SDK_INT < 28
? getSystemProperty("sys.display-size")
: getSystemProperty("vendor.display-size");
if (displaySize == null) {
return getDisplaySize();
}
String[] sizes = displaySize.split("x");
if (sizes.length != 2) {
return getDisplaySize();
}
try {
return new Size(Integer.parseInt(sizes[0]), Integer.parseInt(sizes[1]));
} catch (NumberFormatException e) {
return getDisplaySize();
}
}
@SuppressWarnings("unused")
@UsedByNative
boolean isNetworkConnected() {
return networkStatus.isConnected();
}
/**
* Checks if there is no microphone connected to the system.
*
* @return true if no device is connected.
*/
@SuppressWarnings("unused")
@UsedByNative
public boolean isMicrophoneDisconnected() {
if (Build.VERSION.SDK_INT >= 23) {
return !isMicrophoneConnectedV23();
} else {
// There is no way of checking for a connected microphone/device before API 23, so cannot
// guarantee that no microphone is connected.
return false;
}
}
@RequiresApi(23)
private boolean isMicrophoneConnectedV23() {
// A check specifically for microphones is not available before API 28, so it is assumed that a
// connected input audio device is a microphone.
AudioManager audioManager = (AudioManager) appContext.getSystemService(AUDIO_SERVICE);
AudioDeviceInfo[] devices = audioManager.getDevices(GET_DEVICES_INPUTS);
if (devices.length > 0) {
return true;
}
// fallback to check for BT voice capable RCU
InputManager inputManager = (InputManager) appContext.getSystemService(Context.INPUT_SERVICE);
final int[] inputDeviceIds = inputManager.getInputDeviceIds();
for (int inputDeviceId : inputDeviceIds) {
final InputDevice inputDevice = inputManager.getInputDevice(inputDeviceId);
final boolean hasMicrophone = inputDevice.hasMicrophone();
if (hasMicrophone) {
return true;
}
}
return false;
}
/**
* Checks if the microphone is muted.
*
* @return true if the microphone mute is on.
*/
@SuppressWarnings("unused")
@UsedByNative
public boolean isMicrophoneMute() {
AudioManager audioManager = (AudioManager) appContext.getSystemService(AUDIO_SERVICE);
return audioManager.isMicrophoneMute();
}
/**
* @return true if we have an active network connection and it's on an wireless network.
*/
@SuppressWarnings("unused")
@UsedByNative
boolean isCurrentNetworkWireless() {
if (Build.VERSION.SDK_INT >= 23) {
return isCurrentNetworkWirelessV23();
} else {
return isCurrentNetworkWirelessDeprecated();
}
}
@SuppressWarnings("deprecation")
private boolean isCurrentNetworkWirelessDeprecated() {
ConnectivityManager connMgr =
(ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE);
android.net.NetworkInfo activeInfo = connMgr.getActiveNetworkInfo();
if (activeInfo == null) {
return false;
}
switch (activeInfo.getType()) {
case ConnectivityManager.TYPE_ETHERNET:
return false;
default:
// Consider anything that's not definitely wired to be wireless.
// For example, TYPE_VPN is ambiguous, but it's highly likely to be
// over wifi.
return true;
}
}
@RequiresApi(23)
private boolean isCurrentNetworkWirelessV23() {
ConnectivityManager connMgr =
(ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE);
Network activeNetwork = connMgr.getActiveNetwork();
if (activeNetwork == null) {
return false;
}
NetworkCapabilities activeCapabilities = connMgr.getNetworkCapabilities(activeNetwork);
if (activeCapabilities == null) {
return false;
}
// Consider anything that's not definitely wired to be wireless.
return !activeCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET);
}
/**
* @return true if the user has enabled accessibility high contrast text in the operating system.
*/
@SuppressWarnings("unused")
@UsedByNative
boolean isAccessibilityHighContrastTextEnabled() {
AccessibilityManager am =
(AccessibilityManager) appContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
try {
Method m = AccessibilityManager.class.getDeclaredMethod("isHighTextContrastEnabled");
return m.invoke(am) == Boolean.TRUE;
} catch (ReflectiveOperationException ex) {
return false;
}
}
/** Returns Java layer implementation for AndroidUserAuthorizer */
@SuppressWarnings("unused")
@UsedByNative
public UserAuthorizer getUserAuthorizer() {
return userAuthorizer;
}
@SuppressWarnings("unused")
@UsedByNative
void updateMediaSession(
int playbackState,
long actions,
long positionMs,
float speed,
String title,
String artist,
String album,
MediaImage[] artwork,
long duration) {
cobaltMediaSession.updateMediaSession(
playbackState, actions, positionMs, speed, title, artist, album, artwork, duration);
}
/** Returns string for kSbSystemPropertyUserAgentAuxField */
@SuppressWarnings("unused")
@UsedByNative
protected String getUserAgentAuxField() {
StringBuilder sb = new StringBuilder();
String packageName = appContext.getApplicationInfo().packageName;
sb.append(packageName);
sb.append('/');
try {
sb.append(appContext.getPackageManager().getPackageInfo(packageName, 0).versionName);
} catch (PackageManager.NameNotFoundException ex) {
// Should never happen
Log.e(TAG, "Can't find our own package", ex);
}
return sb.toString();
}
@SuppressWarnings("unused")
@UsedByNative
AudioOutputManager getAudioOutputManager() {
return audioOutputManager;
}
/** Returns Java layer implementation for KeyboardEditor */
@SuppressWarnings("unused")
@UsedByNative
KeyboardEditor getKeyboardEditor() {
return keyboardEditor;
}
/** Returns Java layer implementation for AudioPermissionRequester */
@SuppressWarnings("unused")
@UsedByNative
AudioPermissionRequester getAudioPermissionRequester() {
return audioPermissionRequester;
}
void onActivityResult(int requestCode, int resultCode, Intent data) {
userAuthorizer.onActivityResult(requestCode, resultCode, data);
}
void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
userAuthorizer.onRequestPermissionsResult(requestCode, permissions, grantResults);
audioPermissionRequester.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@SuppressWarnings("unused")
@UsedByNative
public void resetVideoSurface() {
Activity activity = activityHolder.get();
if (activity instanceof CobaltActivity) {
((CobaltActivity) activity).resetVideoSurface();
}
}
@SuppressWarnings("unused")
@UsedByNative
public void setVideoSurfaceBounds(final int x, final int y, final int width, final int height) {
Activity activity = activityHolder.get();
if (activity instanceof CobaltActivity) {
((CobaltActivity) activity).setVideoSurfaceBounds(x, y, width, height);
}
}
long supportedHdrTypesSetUpdatedAtNs;
private void refreshHdrTypesCacheIfNecessary() {
if (System.currentTimeMillis() - supportedHdrTypesSetUpdatedAt < supportedHdrTypesCacheTtlMs) {
// Cache is up to date.
return;
}
supportedHdrTypesSet.clear();
supportedHdrTypesSetUpdatedAt = System.currentTimeMillis();
Display defaultDisplay = DisplayUtil.getDefaultDisplay();
if (defaultDisplay == null) {
return;
}
Display.HdrCapabilities hdrCapabilities = defaultDisplay.getHdrCapabilities();
if (hdrCapabilities == null) {
return;
}
int[] supportedHdrTypes = hdrCapabilities.getSupportedHdrTypes();
if (supportedHdrTypes == null) {
return;
}
for (int supportedType : supportedHdrTypes) {
supportedHdrTypesSet.add(supportedType);
}
}
/**
* Check if hdrType is supported by the current default display. See
* https://developer.android.com/reference/android/view/Display.HdrCapabilities.html for valid
* values.
*/
@RequiresApi(24)
@SuppressWarnings("unused")
@UsedByNative
public boolean isHdrTypeSupported(int hdrType) {
if (android.os.Build.VERSION.SDK_INT < 24) {
return false;
}
synchronized (this) {
refreshHdrTypesCacheIfNecessary();
return supportedHdrTypesSet.contains(hdrType);
}
}
/** Return the CobaltMediaSession. */
public CobaltMediaSession cobaltMediaSession() {
return cobaltMediaSession;
}
public void registerCobaltService(CobaltService.Factory factory) {
cobaltServiceFactories.put(factory.getServiceName(), factory);
}
@SuppressWarnings("unused")
@UsedByNative
boolean hasCobaltService(String serviceName) {
return cobaltServiceFactories.get(serviceName) != null;
}
@SuppressWarnings("unused")
@UsedByNative
CobaltService openCobaltService(long nativeService, String serviceName) {
if (cobaltServices.get(serviceName) != null) {
// Attempting to re-open an already open service fails.
Log.e(TAG, String.format("Cannot open already open service %s", serviceName));
return null;
}
final CobaltService.Factory factory = cobaltServiceFactories.get(serviceName);
if (factory == null) {
Log.e(TAG, String.format("Cannot open unregistered service %s", serviceName));
return null;
}
CobaltService service = factory.createCobaltService(nativeService);
if (service != null) {
service.receiveStarboardBridge(this);
cobaltServices.put(serviceName, service);
}
return service;
}
@SuppressWarnings("unused")
@UsedByNative
void closeCobaltService(String serviceName) {
cobaltServices.remove(serviceName);
}
/** Returns the application start timestamp. */
@SuppressWarnings("unused")
@UsedByNative
protected long getAppStartTimestamp() {
Activity activity = activityHolder.get();
if (activity instanceof CobaltActivity) {
long javaStartTimestamp = ((CobaltActivity) activity).getAppStartTimestamp();
long cppTimestamp = nativeSbTimeGetMonotonicNow();
long javaStopTimestamp = System.nanoTime();
return cppTimestamp
- (javaStartTimestamp - javaStopTimestamp) / timeNanosecondsPerMicrosecond;
}
return 0;
}
}