diff --git a/cobalt/android/BUILD.gn b/cobalt/android/BUILD.gn index 0954e82ee17f1..c8b281c02945d 100644 --- a/cobalt/android/BUILD.gn +++ b/cobalt/android/BUILD.gn @@ -68,6 +68,7 @@ android_library("cobalt_apk_java") { "apk/app/src/main/java/dev/cobalt/coat/javabridge/AmatiDeviceInspector.java", "apk/app/src/main/java/dev/cobalt/coat/javabridge/CobaltJavaScriptAndroidObject.java", "apk/app/src/main/java/dev/cobalt/coat/javabridge/CobaltJavaScriptInterface.java", + "apk/app/src/main/java/dev/cobalt/coat/javabridge/H5vccPlatformService.java", "apk/app/src/main/java/dev/cobalt/coat/javabridge/HTMLMediaElementExtension.java", # "apk/app/src/main/java/dev/cobalt/coat/CobaltMediaSession.java", @@ -115,8 +116,6 @@ android_library("cobalt_apk_java") { android_assets("cobalt_apk_assets") { testonly = true sources = [ - "apk/app/src/app/assets/amati_device_inspector.js", - "apk/app/src/app/assets/html_media_element_extension.js", "apk/app/src/app/assets/not_empty.txt", "apk/app/src/app/assets/test/not_empty.txt", "apk/app/src/app/assets/web/cobalt_blue_splash_screen.css", diff --git a/cobalt/android/apk/app/src/app/assets/html_media_element_extension.js b/cobalt/android/apk/app/src/app/assets/html_media_element_extension.js deleted file mode 100644 index 0bc185b69cef4..0000000000000 --- a/cobalt/android/apk/app/src/app/assets/html_media_element_extension.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @license - * Copyright The Cobalt Authors. - * SPDX-License-Identifier: Apache-2.0 - */ - -HTMLMediaElement.prototype.canPlayType = HTMLMediaElementExtension.canPlayType; -console.log("HTMLMediaElement.canPlayType has been overwritten"); diff --git a/cobalt/android/apk/app/src/app/assets/amati_device_inspector.js b/cobalt/android/apk/app/src/kabuki_polyfill/amati_device_inspector.js similarity index 100% rename from cobalt/android/apk/app/src/app/assets/amati_device_inspector.js rename to cobalt/android/apk/app/src/kabuki_polyfill/amati_device_inspector.js diff --git a/cobalt/android/apk/app/src/kabuki_polyfill/chrobalt_preload.js b/cobalt/android/apk/app/src/kabuki_polyfill/chrobalt_preload.js new file mode 100644 index 0000000000000..6d529d14d28a4 --- /dev/null +++ b/cobalt/android/apk/app/src/kabuki_polyfill/chrobalt_preload.js @@ -0,0 +1,7 @@ +import { initializeH5vccPlatformService } from './h5vcc_platform_service.js'; +import { initializeHTMLMediaElement } from './html_media_element_extension.js'; + +export function chrobaltPreload() { + initializeH5vccPlatformService(); + initializeHTMLMediaElement(); +} diff --git a/cobalt/android/apk/app/src/kabuki_polyfill/h5vcc_platform_service.js b/cobalt/android/apk/app/src/kabuki_polyfill/h5vcc_platform_service.js new file mode 100644 index 0000000000000..b8ba198a423e8 --- /dev/null +++ b/cobalt/android/apk/app/src/kabuki_polyfill/h5vcc_platform_service.js @@ -0,0 +1,74 @@ +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); // Encode binary string to Base64 +} + +function base64ToArrayBuffer(base64) { + const binaryString = window.atob(base64); // Decode Base64 string to binary string + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + +class PlatformServiceClient { + constructor(name) { + this.name = name; + } + + send(data) { + // convert the ArrayBuffer to base64 string because java bridge only takes primitive types as input. + const convertToB64 = arrayBufferToBase64(data); + const responseData = Android_H5vccPlatformService.platformServiceSend(this.name, convertToB64); + if (responseData === "") { + return null; + } + + // response_data has the synchronize response data converted to base64 string. + // convert it to ArrayBuffer, and return the ArrayBuffer to client. + return base64ToArrayBuffer(responseData); + } + + close() { + Android_H5vccPlatformService.closePlatformService(this.name); + } +} + +export function initializeH5vccPlatformService() { + if (typeof Android_H5vccPlatformService === 'undefined') { + return; + } + + // On Chrobalt, register window.H5vccPlatformService + window.H5vccPlatformService = { + // Holds the callback functions for the platform services when open() is called. + callbacks: { + }, + callbackFromAndroid: (serviceID, dataFromJava) => { + const arrayBuffer = base64ToArrayBuffer(dataFromJava); + window.H5vccPlatformService.callbacks[serviceID].callback(serviceID, arrayBuffer); + }, + has: (name) => { + return Android_H5vccPlatformService.hasPlatformService(name); + }, + open: function(name, callback) { + if (typeof callback !== 'function') { + throw new Error("window.H5vccPlatformService.open(), missing or invalid callback function."); + } + + const serviceID = Object.keys(this.callbacks).length + 1; + // Store the callback with the service ID, name, and callback + window.H5vccPlatformService.callbacks[serviceID] = { + name: name, + callback: callback + }; + Android_H5vccPlatformService.openPlatformService(serviceID, name); + return new PlatformServiceClient(name); + }, + } +} diff --git a/cobalt/android/apk/app/src/kabuki_polyfill/html_media_element_extension.js b/cobalt/android/apk/app/src/kabuki_polyfill/html_media_element_extension.js new file mode 100644 index 0000000000000..2a1aef3dfe461 --- /dev/null +++ b/cobalt/android/apk/app/src/kabuki_polyfill/html_media_element_extension.js @@ -0,0 +1,11 @@ +/** + * @license + * Copyright The Cobalt Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +export function initializeHTMLMediaElement() { + if (typeof HTMLMediaElementExtension !== 'undefined') { + HTMLMediaElement.prototype.canPlayType = HTMLMediaElementExtension.canPlayType; + } +} diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/CobaltActivity.java b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/CobaltActivity.java index b04f7bdff3554..1e1b4ef793629 100644 --- a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/CobaltActivity.java +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/CobaltActivity.java @@ -24,8 +24,6 @@ import android.media.AudioManager; import android.net.Uri; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; import android.text.TextUtils; import android.util.Pair; import android.view.KeyEvent; @@ -36,14 +34,13 @@ import android.widget.LinearLayout; import android.widget.Toast; import dev.cobalt.app.CobaltApplication; -import dev.cobalt.coat.javabridge.AmatiDeviceInspector; import dev.cobalt.coat.javabridge.CobaltJavaScriptAndroidObject; import dev.cobalt.coat.javabridge.CobaltJavaScriptInterface; +import dev.cobalt.coat.javabridge.H5vccPlatformService; import dev.cobalt.coat.javabridge.HTMLMediaElementExtension; import dev.cobalt.media.AudioOutputManager; import dev.cobalt.media.MediaCodecCapabilitiesLogger; import dev.cobalt.media.VideoSurfaceView; -import dev.cobalt.util.AssetLoader; import dev.cobalt.util.DisplayUtil; import dev.cobalt.util.Log; import dev.cobalt.util.UsedByNative; @@ -81,7 +78,6 @@ public abstract class CobaltActivity extends Activity { private static final Pattern URL_PARAM_PATTERN = Pattern.compile("^[a-zA-Z0-9_=]*$"); - public static final int JAVA_BRIDGE_INITIALIZATION_DELAY_MILLI_SECONDS = 100; // Maintain the list of JavaScript-exposed objects as a member variable // to prevent them from being garbage collected prematurely. private List javaScriptAndroidObjectList = new ArrayList<>(); @@ -229,7 +225,14 @@ private void finishInitialization(Bundle savedInstanceState) { } // Set to overlay video mode. mShellManager.getContentViewRenderView().setOverlayVideoMode(true); - mShellManager.launchShell(shellUrl); + + // Load an empty page to let shell create WebContents. + mShellManager.launchShell(""); + // Inject JavaBridge objects to the WebContents. + initializeJavaBridge(); + // Load the `url` with the same shell we created above. + Log.i(TAG, "shellManager load url:" + shellUrl); + mShellManager.getActiveShell().loadUrl(shellUrl); toggleFullscreenMode(true); } @@ -361,8 +364,6 @@ protected void onCreate(Bundle savedInstanceState) { videoSurfaceView = new VideoSurfaceView(this); addContentView( videoSurfaceView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); - - initializeJavaBridge(); } /** @@ -374,24 +375,14 @@ private void initializeJavaBridge() { WebContents webContents = getActiveWebContents(); if (webContents == null) { - // WebContents not initialized yet, post a delayed runnable to check again - new Handler(Looper.getMainLooper()) - .postDelayed( - new Runnable() { - @Override - public void run() { - initializeJavaBridge(); // Recursive call to check again - } - }, - JAVA_BRIDGE_INITIALIZATION_DELAY_MILLI_SECONDS); - return; + throw new RuntimeException("webContents is null in initializeJavaBridge. This should never happen."); } // --- Initialize the Java Bridge --- // 1. Gather all Java objects that need to be exposed to JavaScript. // TODO(b/379701165): consider to refine the way to add JavaScript interfaces. - javaScriptAndroidObjectList.add(new AmatiDeviceInspector(this)); + javaScriptAndroidObjectList.add(new H5vccPlatformService(this, getStarboardBridge())); javaScriptAndroidObjectList.add(new HTMLMediaElementExtension(this)); // 2. Use JavascriptInjector to inject Java objects into the WebContents. @@ -408,16 +399,6 @@ public void run() { javascriptAndroidObject.getJavaScriptInterfaceName(), CobaltJavaScriptInterface.class); } - - // 3. Load and evaluate JavaScript code that interacts with the injected Java objects. - for (CobaltJavaScriptAndroidObject javaScriptAndroidObject : javaScriptAndroidObjectList) { - String jsFileName = javaScriptAndroidObject.getJavaScriptAssetName(); - if (jsFileName != null) { - Log.d(TAG, "Evaluate JavaScript from Asset:" + jsFileName); - String jsCode = AssetLoader.loadJavaScriptFromAssets(this, jsFileName); - webContents.evaluateJavaScript(jsCode, null); - } - } } /** @@ -695,4 +676,18 @@ public void onLowMemory() { public long getAppStartTimestamp() { return timeInNanoseconds; } + + public void evaluateJavaScript(String jsCode) { + // evaluateJavaScript must run on UI thread. + runOnUiThread( + new Runnable() { + @Override + public void run() { + WebContents webContents = getActiveWebContents(); + if (webContents != null) { + webContents.evaluateJavaScript(jsCode, null); + } + } + }); + } } diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/CobaltService.java b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/CobaltService.java index f63f1e3aa2d2c..53ba0ec73f1b7 100644 --- a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/CobaltService.java +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/CobaltService.java @@ -16,6 +16,7 @@ import static dev.cobalt.util.Log.TAG; +import android.util.Base64; import dev.cobalt.util.Log; import dev.cobalt.util.UsedByNative; @@ -24,6 +25,8 @@ public abstract class CobaltService { // Indicate is the service opened, and be able to send data to client protected boolean opened = true; private final Object lock = new Object(); + private StarboardBridge bridge; + protected CobaltActivity cobaltActivity; /** Interface that returns an object that extends CobaltService. */ public interface Factory { @@ -37,6 +40,10 @@ public interface Factory { /** Take in a reference to StarboardBridge & use it as needed. Default behavior is no-op. */ public void receiveStarboardBridge(StarboardBridge bridge) {} + public void setCobaltActivity(CobaltActivity cobaltActivity) { + this.cobaltActivity = cobaltActivity; + } + // Lifecycle /** Prepare service for start or resume. */ public abstract void beforeStartOrResume(); @@ -87,13 +94,16 @@ public void onClose() { /** * Send data from the service to the client. - * */ protected void sendToClient(long nativeService, byte[] data) { - // TODO(b/372558900): Implement Javascript Injection - } + if (this.cobaltActivity == null) { + Log.e(TAG, "CobaltActivity is null, can not run evaluateJavaScript()"); + return; + } - private void nativeSendToClient(long nativeService, byte[] data) { - // TODO(b/372558900): Implement Javascript Injection + // Use Base64.NO_WRAP instead of Base64.DEFAULT to avoid adding a new line. + String base64Data = Base64.encodeToString(data, Base64.NO_WRAP); + String jsCode = String.format("window.H5vccPlatformService.callbackFromAndroid(%d, '%s');", nativeService, base64Data); + this.cobaltActivity.evaluateJavaScript(jsCode); } } diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/StarboardBridge.java b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/StarboardBridge.java index e8af16173ea42..731f3015ed2f4 100644 --- a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/StarboardBridge.java +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/StarboardBridge.java @@ -735,15 +735,11 @@ public void registerCobaltService(CobaltService.Factory factory) { cobaltServiceFactories.put(factory.getServiceName(), factory); } - @SuppressWarnings("unused") - @UsedByNative - boolean hasCobaltService(String serviceName) { + public boolean hasCobaltService(String serviceName) { return cobaltServiceFactories.get(serviceName) != null; } - @SuppressWarnings("unused") - @UsedByNative - CobaltService openCobaltService(long nativeService, String serviceName) { + public 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)); @@ -758,6 +754,11 @@ CobaltService openCobaltService(long nativeService, String serviceName) { if (service != null) { service.receiveStarboardBridge(this); cobaltServices.put(serviceName, service); + + Activity activity = activityHolder.get(); + if (activity instanceof CobaltActivity) { + service.setCobaltActivity((CobaltActivity) activity); + } } return service; } @@ -766,12 +767,25 @@ public CobaltService getOpenedCobaltService(String serviceName) { return cobaltServices.get(serviceName); } - @SuppressWarnings("unused") - @UsedByNative - void closeCobaltService(String serviceName) { + public void closeCobaltService(String serviceName) { cobaltServices.remove(serviceName); } + public byte[] sendToCobaltService(String serviceName, byte [] data) { + CobaltService service = cobaltServices.get(serviceName); + if (service == null) { + Log.e(TAG, String.format("Service not opened: %s", serviceName)); + return null; + } + CobaltService.ResponseToClient response = service.receiveFromClient(data); + if (response.invalidState) { + Log.e(TAG, String.format("Service %s received invalid data, closing.", serviceName)); + closeCobaltService(serviceName); + return null; + } + return response.data; + } + /** Returns the application start timestamp. */ @SuppressWarnings("unused") @UsedByNative diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/AmatiDeviceInspector.java b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/AmatiDeviceInspector.java index 66e4633adbc81..85e9cebc4d31c 100644 --- a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/AmatiDeviceInspector.java +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/AmatiDeviceInspector.java @@ -37,11 +37,6 @@ public String getJavaScriptInterfaceName() { return "Android_AmatiDeviceInspector"; } - @Override - public String getJavaScriptAssetName() { - return "amati_device_inspector.js"; - } - @CobaltJavaScriptInterface public void printIsAmatiDevice() { boolean isAmatiDevice = context.getPackageManager().hasSystemFeature("com.google.android.feature.AMATI_EXPERIENCE"); diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/CobaltJavaScriptAndroidObject.java b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/CobaltJavaScriptAndroidObject.java index ab17b10f56969..175ecaf25aefd 100644 --- a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/CobaltJavaScriptAndroidObject.java +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/CobaltJavaScriptAndroidObject.java @@ -14,8 +14,6 @@ package dev.cobalt.coat.javabridge; -import androidx.annotation.Nullable; - /** * Interface for Android objects that are exposed to JavaScript. */ @@ -28,12 +26,4 @@ public interface CobaltJavaScriptAndroidObject { * @return The JavaScript interface name. */ public String getJavaScriptInterfaceName(); - - /** - * Gets the name of the JavaScript asset file that uses this interface. - * This allows the JavaScript code to be loaded and interact with this object. - * - * @return The name of the JavaScript asset file, or null if not applicable. - */ - public @Nullable String getJavaScriptAssetName(); } diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/H5vccPlatformService.java b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/H5vccPlatformService.java new file mode 100644 index 0000000000000..c5c4f9a9af347 --- /dev/null +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/H5vccPlatformService.java @@ -0,0 +1,64 @@ +// Copyright 2024 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.javabridge; + +import android.content.Context; +import android.util.Base64; +import dev.cobalt.coat.StarboardBridge; + +/** + * H5vccPlatformService JavaScript object. + */ +public class H5vccPlatformService implements CobaltJavaScriptAndroidObject { + + private final Context context; + private final StarboardBridge bridge; + + public H5vccPlatformService(Context context, StarboardBridge bridge) { + this.context = context; + this.bridge = bridge; + } + + @Override + public String getJavaScriptInterfaceName() { + return "Android_H5vccPlatformService"; + } + + @CobaltJavaScriptInterface + public boolean hasPlatformService(String servicename) { + return bridge.hasCobaltService(servicename); + } + + @CobaltJavaScriptInterface + public void openPlatformService(long serviceId, String servicename) { + bridge.openCobaltService(serviceId, servicename); + } + + @CobaltJavaScriptInterface + public void closePlatformService(String servicename) { + bridge.closeCobaltService(servicename); + } + + @CobaltJavaScriptInterface + public String platformServiceSend(String servicename, String base64Data) { + byte[] data = Base64.decode(base64Data, Base64.DEFAULT); + byte[] result = bridge.sendToCobaltService(servicename, data); + if (result == null) { + return ""; + } + + return Base64.encodeToString(result, Base64.DEFAULT); + } +} diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/HTMLMediaElementExtension.java b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/HTMLMediaElementExtension.java index 528819d1a9a46..09b7129add73a 100644 --- a/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/HTMLMediaElementExtension.java +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/coat/javabridge/HTMLMediaElementExtension.java @@ -30,11 +30,6 @@ public String getJavaScriptInterfaceName() { return "HTMLMediaElementExtension"; } - @Override - public String getJavaScriptAssetName() { - return "html_media_element_extension.js"; - } - @CobaltJavaScriptInterface public String canPlayType(String mimeType, String keySystem) { return nativeCanPlayType(mimeType, keySystem); diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/ClientLogInfo.java b/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/ClientLogInfo.java index b9eb75005b5f8..a4e9bf01dfce8 100644 --- a/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/ClientLogInfo.java +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/ClientLogInfo.java @@ -7,6 +7,10 @@ import dev.cobalt.util.DisplayUtil; import dev.cobalt.util.Log; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; + + /** ClientLogInfo to report Android API support on android devices. */ public class ClientLogInfo extends CobaltService { public static final String TAG = "ClientLogInfo"; @@ -15,9 +19,15 @@ public class ClientLogInfo extends CobaltService { protected static final String SERVICE_NAME = "dev.cobalt.coat.clientloginfo"; private static String clientInfo = ""; + private final long nativeService; + private final ThreadPoolExecutor executor; public ClientLogInfo(Context appContext, long nativeService) { Log.i(TAG, "Opening ClientLogInfo"); + this.nativeService = nativeService; + + // Create a ThreadPoolExecutor with a fixed number of threads + this.executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(1); } @Override @@ -31,13 +41,28 @@ public void afterStopped() {} @Override public ResponseToClient receiveFromClient(byte[] data) { + String dataString = new String(data, UTF_8); + Log.i(TAG, "Received data from platform service client:" + dataString); + ResponseToClient response = new ResponseToClient(); response.invalidState = false; - String responseString = - "displayRefreshRate:" + DisplayUtil.getDefaultDisplayRefreshRate() + ";"; - responseString += clientInfo; + final String responseString = + "displayRefreshRate:" + DisplayUtil.getDefaultDisplayRefreshRate() + ";" + clientInfo; + + // synchronize response response.data = responseString.getBytes(UTF_8); + + // Submit a Runnable task to send async response + executor.execute( + () -> { + String asynResponseString = "async response: " + responseString; + Log.i(TAG, "Platform service send async responseString:" + asynResponseString); + sendToClient(nativeService, asynResponseString.getBytes(UTF_8)); + } + ); + + Log.i(TAG, "Platform service send sync responseString:" + responseString); return response; } diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/chrobalt_preload.js b/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/chrobalt_preload.js new file mode 100644 index 0000000000000..b1fd4d96fdced --- /dev/null +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/chrobalt_preload.js @@ -0,0 +1,5 @@ +import { initializeH5vccPlatformService } from './h5vcc_platform_service.js'; + +export function chrobaltPreload() { + initializeH5vccPlatformService(); +} diff --git a/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/client_log_info_demo.html b/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/client_log_info_demo.html index 79b7993ff8a41..673338d8bb0e1 100644 --- a/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/client_log_info_demo.html +++ b/cobalt/android/apk/app/src/main/java/dev/cobalt/libraries/services/clientloginfo/client_log_info_demo.html @@ -9,7 +9,10 @@ -