diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 4d8ffaddee..6261d9580f 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -170,6 +170,11 @@ android:name="android.permission.UPDATE_APP_OPS_STATS" tools:ignore="ProtectedPermissions" /> + + + + + + diff --git a/play-services-core/src/main/java/org/microg/gms/settings/GmsFileProvider.kt b/play-services-core/src/main/java/org/microg/gms/settings/GmsFileProvider.kt new file mode 100644 index 0000000000..39f2b2374c --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/settings/GmsFileProvider.kt @@ -0,0 +1,69 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.settings + +import android.content.Context +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.util.Log +import androidx.core.content.FileProvider +import java.io.FileNotFoundException + +private const val TAG = "GmsFileProvider" + +class GmsFileProvider : FileProvider() { + private val emptyProjection = arrayOfNulls(0) + private var initializationFailed = false + + override fun attachInfo(context: Context, info: ProviderInfo) { + try { + super.attachInfo(context, info) + } catch (e: Exception) { + initializationFailed = true + Log.e(TAG, "attachInfo error:${e.message}") + } + } + + override fun onCreate(): Boolean { + return true + } + + override fun getType(uri: Uri): String? { + if (initializationFailed) { + return null + } + return super.getType(uri) + } + + override fun openFile( + uri: Uri, mode: String, signal: CancellationSignal? + ): ParcelFileDescriptor? { + if (!initializationFailed) { + return super.openFile(uri, mode, signal) + } + throw FileNotFoundException("FileProvider creation failed") + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + if (initializationFailed) { + return 0 + } + return super.delete(uri, selection, selectionArgs) + } + + override fun query( + uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String? + ): Cursor { + if (initializationFailed) { + return MatrixCursor(emptyProjection) + } + return super.query(uri, projection, selection, selectionArgs, sortOrder) + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/MainActivity.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/MainActivity.kt index a08ac95637..c1b6108438 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/MainActivity.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/MainActivity.kt @@ -7,26 +7,49 @@ package org.microg.gms.accountsettings.ui import android.accounts.Account import android.accounts.AccountManager -import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface +import android.net.Uri +import android.os.Build.VERSION.SDK_INT import android.os.Bundle import android.text.TextUtils import android.util.Log +import android.view.Gravity import android.view.View -import android.webkit.JavascriptInterface +import android.webkit.ValueCallback import android.webkit.WebView import android.widget.ProgressBar import android.widget.RelativeLayout import android.widget.RelativeLayout.LayoutParams.MATCH_PARENT import android.widget.RelativeLayout.LayoutParams.WRAP_CONTENT +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONException -import org.json.JSONObject +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import com.google.android.gms.R +import org.microg.gms.accountsettings.ui.bridge.OcAdvertisingIdBridge +import org.microg.gms.accountsettings.ui.bridge.OcAndroidIdBridge +import org.microg.gms.accountsettings.ui.bridge.OcAppBarBridge +import org.microg.gms.accountsettings.ui.bridge.OcAppPermissionsBridge +import org.microg.gms.accountsettings.ui.bridge.OcClientInfoBridge +import org.microg.gms.accountsettings.ui.bridge.OcConsistencyBridge +import org.microg.gms.accountsettings.ui.bridge.OcContactsBridge +import org.microg.gms.accountsettings.ui.bridge.OcFido2Bridge +import org.microg.gms.accountsettings.ui.bridge.OcFidoU2fBridge +import org.microg.gms.accountsettings.ui.bridge.OcFilePickerBridge +import org.microg.gms.accountsettings.ui.bridge.OcFolsomBridge +import org.microg.gms.accountsettings.ui.bridge.OcPermissionsBridge +import org.microg.gms.accountsettings.ui.bridge.OcPlayProtectBridge +import org.microg.gms.accountsettings.ui.bridge.OcTelephonyBridge +import org.microg.gms.accountsettings.ui.bridge.OcTrustAgentBridge +import org.microg.gms.accountsettings.ui.bridge.OcUdcBridge +import org.microg.gms.accountsettings.ui.bridge.OcUiBridge import org.microg.gms.auth.AuthConstants import org.microg.gms.common.Constants import org.microg.gms.people.PeopleManager +import org.microg.gms.profile.ProfileManager +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors private const val TAG = "AccountSettings" @@ -62,7 +85,7 @@ private val SCREEN_ID_TO_URL = hashMapOf( 310 to "https://myaccount.google.com/reservations", 312 to "https://myaccount.google.com/accessibility", 313 to "https://myaccount.google.com/inputtools", - 400 to "https://myaccount.google.com/security-checkup/", + 400 to "https://myaccount.google.com/security-checkup", 401 to "https://myaccount.google.com/signinoptions/password", 403 to "https://myaccount.google.com/signinoptions/two-step-verification", 406 to "https://myaccount.google.com/signinoptions/rescuephone", @@ -94,7 +117,7 @@ private val SCREEN_ID_TO_URL = hashMapOf( 10007 to "https://myaccount.google.com/payments-and-subscriptions", 10015 to "https://support.google.com/accounts", 10050 to "https://myaccount.google.com/profile", - 10052 to "https://myaccount.google.com/embedded/family/create", + 10052 to "https://myaccount.google.com/family/details", 10090 to "https://myaccount.google.com/profile/name", 10704 to "https://www.google.com/account/about", 10706 to "https://myaccount.google.com/profile/profiles-summary", @@ -134,11 +157,20 @@ private val ACTION_TO_SCREEN_ID = hashMapOf( class MainActivity : AppCompatActivity() { private lateinit var webView: WebView - private var accountName: String? = null - private var resultBundle: Bundle? = null + private val executor: ExecutorService = Executors.newSingleThreadExecutor() private fun getSelectedAccountName(): String? = null + private var filePathCallback: ValueCallback>? = null + private val pickerUtils = PicturePickerUtils(this, { + filePathCallback?.onReceiveValue(if (it != null) arrayOf(it) else emptyArray()) + filePathCallback = null + }, { + Log.d(TAG, "Picker error : ${it.name}<${it.value}>") + filePathCallback?.onReceiveValue(emptyArray()) + filePathCallback = null + }) + override fun onCreate(savedInstanceState: Bundle?) { val extras = intent?.extras?.also { it.keySet() } Log.d(TAG, "Invoked with ${intent.action} and extras $extras") @@ -156,7 +188,7 @@ class MainActivity : AppCompatActivity() { val callingPackage = intent?.getStringExtra(EXTRA_CALLING_PACKAGE_NAME) ?: callingActivity?.packageName ?: Constants.GMS_PACKAGE_NAME val ignoreAccount = intent?.getBooleanExtra(EXTRA_IGNORE_ACCOUNT, false) ?: false - accountName = if (ignoreAccount) null else { + val accountName = if (ignoreAccount) null else { val accounts = AccountManager.get(this).getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE) val accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME) ?: intent.getParcelableExtra("account")?.name ?: getSelectedAccountName() accounts.find { it.name.equals(accountName) }?.name @@ -175,18 +207,45 @@ class MainActivity : AppCompatActivity() { } else { this } } val layout = RelativeLayout(this) - layout.addView(ProgressBar(this).apply { + val titleView = TextView(this).apply { + text = ContextCompat.getString(context, R.string.pref_accounts_title) + textSize = 20f + setTextColor(Color.BLACK) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)) + layoutParams = Toolbar.LayoutParams(MATCH_PARENT, WRAP_CONTENT, Gravity.START) + } + val toolbar = Toolbar(this).apply { + id = View.generateViewId() + setBackgroundColor(Color.WHITE) + if (SDK_INT >= 21) { + backgroundTintList = null + } + layoutParams = RelativeLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + addRule(RelativeLayout.ALIGN_PARENT_TOP) + } + navigationIcon = ContextCompat.getDrawable(context, R.drawable.ic_arrow_close) + setNavigationOnClickListener { finish() } + addView(titleView) + } + val progressBar = ProgressBar(this).apply { + id = View.generateViewId() layoutParams = RelativeLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { - addRule(RelativeLayout.CENTER_HORIZONTAL) - addRule(RelativeLayout.CENTER_VERTICAL) + addRule(RelativeLayout.CENTER_IN_PARENT) } isIndeterminate = true - }) + } webView = WebView(this).apply { - layoutParams = RelativeLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + id = View.generateViewId() + layoutParams = RelativeLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { + addRule(RelativeLayout.BELOW, toolbar.id) + } visibility = View.INVISIBLE - addJavascriptInterface(UiBridge(), "ocUi") + loadJsBridge(accountName, toolbar) } + layout.addView(toolbar) + layout.addView(progressBar) layout.addView(webView) setContentView(layout) WebViewHelper(this, webView, ALLOWED_WEB_PREFIXES).openWebView(screenUrl, accountName, callingPackage) @@ -200,6 +259,9 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() + if (!executor.isShutdown) { + executor.shutdown() + } } override fun onBackPressed() { @@ -210,125 +272,39 @@ class MainActivity : AppCompatActivity() { } } - private fun updateLocalAccountAvatar(newAvatarUrl: String?) { - if (TextUtils.isEmpty(newAvatarUrl) || accountName == null) { - return - } - lifecycleScope.launchWhenCreated { - withContext(Dispatchers.IO) { - PeopleManager.updateOwnerAvatar(this@MainActivity, accountName, newAvatarUrl) - } - } + private fun WebView.loadJsBridge(accountName: String?, toolbar: Toolbar) { + ProfileManager.ensureInitialized(this@MainActivity) + addJavascriptInterface(OcUiBridge(this@MainActivity, accountName, this), OcUiBridge.NAME) + addJavascriptInterface(OcConsistencyBridge(), OcConsistencyBridge.NAME) + addJavascriptInterface(OcAppBarBridge(toolbar, this), OcAppBarBridge.NAME) + addJavascriptInterface(OcPlayProtectBridge(this), OcPlayProtectBridge.NAME) + addJavascriptInterface(OcTrustAgentBridge(this), OcTrustAgentBridge.NAME) + addJavascriptInterface(OcPermissionsBridge(this), OcPermissionsBridge.NAME) + addJavascriptInterface(OcFido2Bridge(this), OcFido2Bridge.NAME) + addJavascriptInterface(OcClientInfoBridge(), OcClientInfoBridge.NAME) + addJavascriptInterface(OcTelephonyBridge(), OcTelephonyBridge.NAME) + addJavascriptInterface(OcUdcBridge(this), OcUdcBridge.NAME) + addJavascriptInterface(OcAdvertisingIdBridge(this@MainActivity), OcAdvertisingIdBridge.NAME) + addJavascriptInterface(OcAndroidIdBridge(this@MainActivity), OcAndroidIdBridge.NAME) + addJavascriptInterface(OcAppPermissionsBridge(this), OcAppPermissionsBridge.NAME) + addJavascriptInterface(OcFolsomBridge(), OcFolsomBridge.NAME) + addJavascriptInterface(OcFidoU2fBridge(this), OcFidoU2fBridge.NAME) + addJavascriptInterface(OcContactsBridge(this), OcContactsBridge.NAME) + addJavascriptInterface(OcFilePickerBridge(this@MainActivity, this, executor), OcFilePickerBridge.NAME) } - private inner class UiBridge { - - @JavascriptInterface - fun close() { - Log.d(TAG, "close: ") - val intent = Intent() - if (resultBundle != null) { - intent.putExtras(resultBundle!!) - } - setResult(RESULT_OK, intent) - finish() - } - - @JavascriptInterface - fun closeWithResult(resultJsonStr: String?) { - Log.d(TAG, "closeWithResult: resultJsonStr -> $resultJsonStr") - setResult(resultJsonStr) - close() - } - - @JavascriptInterface - fun goBackOrClose() { - Log.d(TAG, "goBackOrClose: ") - onBackPressed() - } - - @JavascriptInterface - fun hideKeyboard() { - Log.d(TAG, "hideKeyboard: ") - } - - @JavascriptInterface - fun isCloseWithResultSupported(): Boolean { - return true - } - - @JavascriptInterface - fun isOpenHelpEnabled(): Boolean { - return true - } - - @JavascriptInterface - fun isOpenScreenEnabled(): Boolean { - return true - } - - @JavascriptInterface - fun isSetResultSupported(): Boolean { - return true - } - - @JavascriptInterface - fun open(str: String?) { - Log.d(TAG, "open: str -> $str") - } - - @JavascriptInterface - fun openHelp(str: String?) { - Log.d(TAG, "openHelp: str -> $str") - } - - @JavascriptInterface - fun openScreen(screenId: Int, str: String?) { - Log.d(TAG, "openScreen: screenId -> $screenId str -> $str accountName -> $accountName") - val intent = Intent(this@MainActivity, MainActivity::class.java).apply { - putExtra(EXTRA_SCREEN_ID, screenId) - putExtra(EXTRA_ACCOUNT_NAME, accountName) - } - startActivity(intent) - } - - @JavascriptInterface - fun setBackStop() { - Log.d(TAG, "setBackStop: ") - webView.clearHistory() - } + fun showImageChooser(targetFilePathCallback: ValueCallback>) { + filePathCallback?.onReceiveValue(null) + filePathCallback = targetFilePathCallback + pickerUtils.launchChooser("*/*") + } - @JavascriptInterface - fun setResult(resultJsonStr: String?) { - Log.d(TAG, "setResult: resultJsonStr -> $resultJsonStr") - val map = jsonToMap(resultJsonStr) ?: return - if (map.containsKey(KEY_UPDATED_PHOTO_URL)) { - updateLocalAccountAvatar(map[KEY_UPDATED_PHOTO_URL]) - } - resultBundle = Bundle().apply { - for ((key, value) in map) { - putString("result.$key", value) - } - } + fun updateLocalAccountAvatar(newAvatarUrl: String?, accountName: String?) { + if (TextUtils.isEmpty(newAvatarUrl) || accountName == null) { + return } - - private fun jsonToMap(jsonStr: String?): Map? { - val hashMap = HashMap() - if (!jsonStr.isNullOrEmpty()) { - try { - val jSONObject = JSONObject(jsonStr) - val keys = jSONObject.keys() - while (keys.hasNext()) { - val next = keys.next() - val obj = jSONObject[next] - hashMap[next] = obj as String - } - } catch (e: JSONException) { - Log.d(TAG, "Unable to parse result JSON string", e) - return null - } - } - return hashMap + executor.submit { + PeopleManager.updateOwnerAvatar(this, accountName, newAvatarUrl) } } } \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/PicturePickerUtils.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/PicturePickerUtils.kt new file mode 100644 index 0000000000..268699295e --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/PicturePickerUtils.kt @@ -0,0 +1,115 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.MediaStore +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import java.io.File + +private const val CAMERA_TEMP_DIR = "octa_camera_temp" +private const val TAG = "PicturePickerUtils" + +class PicturePickerUtils(private val activity: MainActivity, private val resultCallback: (Uri?) -> Unit, private val errorCallback: (ResultStatus) -> Unit) { + private lateinit var fileChooserLauncher: ActivityResultLauncher + private var currentPhotoUri: Uri? = null + + init { + initializeChooserLauncher() + } + + private fun initializeChooserLauncher() { + fileChooserLauncher = activity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data?.data + if (uri != null) { + resultCallback(uri) + } else if (currentPhotoUri != null) { + resultCallback(currentPhotoUri) + } else { + errorCallback(ResultStatus.FAILED) + } + } else { + errorCallback(ResultStatus.USER_CANCEL) + } + } + } + + fun launchChooser(mimeType: String) { + if (mimeType.startsWith("image/") || mimeType == "image/*" || mimeType == "*/*") { + when { + ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> { + launchChooserInternal(mimeType) + } + + else -> { + launchFilePickerOnly(mimeType) + } + } + } else { + launchFilePickerOnly(mimeType) + } + } + + private fun launchFilePickerOnly(mimeType: String) { + val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = mimeType + addCategory(Intent.CATEGORY_OPENABLE) + } + fileChooserLauncher.launch(getContentIntent) + } + + private fun launchChooserInternal(mimeType: String) { + val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = mimeType + addCategory(Intent.CATEGORY_OPENABLE) + } + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + if (takePictureIntent.resolveActivity(activity.packageManager) != null) { + currentPhotoUri = createImageUri() + if (currentPhotoUri != null) { + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri) + val chooserIntent = Intent.createChooser(getContentIntent, "Choose") + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(takePictureIntent)) + fileChooserLauncher.launch(chooserIntent) + } else { + fileChooserLauncher.launch(getContentIntent) + } + } else { + fileChooserLauncher.launch(getContentIntent) + } + } + + private fun createImageUri(): Uri? { + try { + val cacheDir = activity.cacheDir + val cameraDir = File(cacheDir, CAMERA_TEMP_DIR) + if (!cameraDir.exists()) { + cameraDir.mkdirs() + } + val photoFile = File(cameraDir, "camera_temp.jpg") + if (photoFile.exists()) { + photoFile.delete() + } + photoFile.createNewFile() + return FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", photoFile) + } catch (e: Exception) { + Log.w(TAG, "createImageUri: ", e) + return null + } + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/WebViewHelper.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/WebViewHelper.kt index 25ddc5e075..f11bc46128 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/WebViewHelper.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/WebViewHelper.kt @@ -9,13 +9,16 @@ import android.content.Intent import android.content.Intent.URI_INTENT_SCHEME import android.net.Uri import android.os.Build.VERSION.SDK_INT +import android.provider.Settings import android.util.Log import android.view.View import android.webkit.CookieManager +import android.webkit.ValueCallback +import android.webkit.WebChromeClient import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView -import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.webkit.WebResourceErrorCompat import androidx.webkit.WebViewClientCompat @@ -23,8 +26,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject +import org.microg.gms.auth.AuthConstants import org.microg.gms.auth.AuthManager -import org.microg.gms.common.Constants +import org.microg.gms.auth.login.LoginActivity import org.microg.gms.common.Constants.GMS_PACKAGE_NAME import org.microg.gms.common.PackageUtils import java.net.URLEncoder @@ -32,9 +36,20 @@ import java.util.* private const val TAG = "AccountSettingsWebView" -class WebViewHelper(private val activity: AppCompatActivity, private val webView: WebView, private val allowedPrefixes: Set = emptySet()) { +class WebViewHelper(private val activity: MainActivity, private val webView: WebView, private val allowedPrefixes: Set = emptySet()) { + private var saveUserAvatar = false fun openWebView(url: String?, accountName: String?, callingPackage: String? = null) { - prepareWebViewSettings(webView.settings) + prepareWebViewSettings(webView.settings, callingPackage) + webView.webChromeClient = object : WebChromeClient() { + override fun onShowFileChooser( + view: WebView?, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams + ): Boolean { + activity.showImageChooser(filePathCallback) + return true + } + } webView.webViewClient = object : WebViewClientCompat() { override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceErrorCompat) { Log.w(TAG, "Error loading: $error") @@ -45,6 +60,26 @@ class WebViewHelper(private val activity: AppCompatActivity, private val webView webView.visibility = View.VISIBLE } + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { + if (SDK_INT >= 21) { + val requestUrl = request?.url?.toString() ?: return super.shouldInterceptRequest(view, request) + Log.d(TAG, "shouldInterceptRequest to $requestUrl") + try { + if (saveUserAvatar && isGoogleAvatarUrl(requestUrl)) { + activity.updateLocalAccountAvatar(requestUrl, accountName) + saveUserAvatar = false + } + val overrideUri = Uri.parse(requestUrl) + if (overrideUri.getQueryParameter("source-path") == "/profile-picture/updating") { + saveUserAvatar = true + } + } catch (e: Exception) { + Log.d(TAG, "shouldInterceptRequest: error", e) + } + } + return super.shouldInterceptRequest(view, request) + } + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { Log.d(TAG, "Navigating to $url") if (url.startsWith("intent:")) { @@ -53,6 +88,7 @@ class WebViewHelper(private val activity: AppCompatActivity, private val webView if (intent.`package` == GMS_PACKAGE_NAME || PackageUtils.isGooglePackage(activity, intent.`package`)) { // Only allow to start Google packages activity.startActivity(intent) + return true } else { Log.w(TAG, "Ignoring request to start non-Google app") } @@ -61,10 +97,21 @@ class WebViewHelper(private val activity: AppCompatActivity, private val webView } return false } + val overrideUri = Uri.parse(url) + if (overrideUri.path?.endsWith("/signin/identifier") == true) { + val intent = Intent(activity, LoginActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + activity.startActivity(intent) + return true + } + if (overrideUri.path?.endsWith("/Logout") == true) { + val intent = Intent(Settings.ACTION_SYNC_SETTINGS).apply { putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(AuthConstants.DEFAULT_ACCOUNT_TYPE)) } + activity.startActivity(intent) + return true + } if (allowedPrefixes.isNotEmpty() && allowedPrefixes.none { url.startsWith(it) }) { try { // noinspection UnsafeImplicitIntentLaunch - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { addCategory(Intent.CATEGORY_BROWSABLE) } + val intent = Intent(Intent.ACTION_VIEW, overrideUri).apply { addCategory(Intent.CATEGORY_BROWSABLE) } if (callingPackage?.let { PackageUtils.isGooglePackage(activity, it) } == true) { try { intent.`package` = GMS_PACKAGE_NAME @@ -78,6 +125,7 @@ class WebViewHelper(private val activity: AppCompatActivity, private val webView } catch (e: Exception) { Log.w(TAG, "Error forwarding to browser", e) } + activity.finish() return true } return false @@ -138,7 +186,7 @@ class WebViewHelper(private val activity: AppCompatActivity, private val webView } } - private fun prepareWebViewSettings(settings: WebSettings) { + private fun prepareWebViewSettings(settings: WebSettings, callingPackage:String?) { settings.javaScriptEnabled = true settings.setSupportMultipleWindows(false) settings.allowFileAccess = false @@ -150,6 +198,9 @@ class WebViewHelper(private val activity: AppCompatActivity, private val webView settings.userAgentString = "${settings.userAgentString} ${ String.format(Locale.getDefault(), "OcIdWebView (%s)", JSONObject().apply { put("os", "Android") + put("osVersion", SDK_INT) + put("app", GMS_PACKAGE_NAME) + put("callingAppId", callingPackage ?: "") }.toString()) }" } diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAdvertisingIdBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAdvertisingIdBridge.kt new file mode 100644 index 0000000000..45b6fcb6b4 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAdvertisingIdBridge.kt @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.content.Context +import android.util.Log +import android.webkit.JavascriptInterface +import com.google.android.gms.ads.identifier.AdvertisingIdClient + +class OcAdvertisingIdBridge(val context: Context) { + + companion object { + const val NAME = "ocAdvertisingId" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun getAdvertisingId(): String? { + Log.d(TAG, "getAdvertisingId: ") + return AdvertisingIdClient.getAdvertisingIdInfo(context).id + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAndroidIdBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAndroidIdBridge.kt new file mode 100644 index 0000000000..f6531191b8 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAndroidIdBridge.kt @@ -0,0 +1,27 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.content.Context +import android.util.Log +import android.webkit.JavascriptInterface +import org.microg.gms.checkin.LastCheckinInfo + +class OcAndroidIdBridge(val context: Context) { + + companion object { + const val NAME = "ocAndroidId" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun getAndroidId(): String? { + Log.d(TAG, "getAndroidId: ") + val androidId = LastCheckinInfo.read(context).androidId + return if (androidId != 0L) androidId.toString(16) else null + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAppBarBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAppBarBridge.kt new file mode 100644 index 0000000000..d6e2455401 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAppBarBridge.kt @@ -0,0 +1,132 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.text.TextUtils +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.widget.TextView +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import com.google.android.gms.R +import org.microg.gms.accountsettings.ui.runOnMainLooper + +class OcAppBarBridge(val toolBar: Toolbar, val webView: WebView) { + + companion object { + const val NAME = "ocAppBar" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun clear() { + Log.d(TAG, "clear: ") + setTitleText(null) + setTitleType(1) + setTitleFontFamily(0) + setStyle(1) + setAccountDisplay(1) + setUpButtonAction(1) + setHelpContext(null) + setActionMenu(null) + setShadowVisible(true) + setUpButtonVisible(true) + setPullToRefreshEnabled(true) + } + + @JavascriptInterface + fun commitChanges() { + Log.d(TAG, "commitChanges: ") + } + + @JavascriptInterface + fun show(id: Double?) { + Log.d(TAG, "show: id: $id") + } + + @JavascriptInterface + fun hide(id: Double?) { + Log.d(TAG, "hide: id: $id") + } + + @JavascriptInterface + fun isNewAppBarFeaturesSupported(): Boolean { + Log.d(TAG, "isNewAppBarFeaturesSupported: ") + return true + } + + @JavascriptInterface + fun setAccountDisplay(displayId: Int?) { + Log.d(TAG, "setAccountDisplay: displayId: $displayId") + } + + @JavascriptInterface + fun setActionMenu(base64Str: String?) { + Log.d(TAG, "setActionMenu: base64Str: $base64Str") + } + + @JavascriptInterface + fun setHelpContext(url: String?) { + Log.d(TAG, "setHelpContext: url: $url") + } + + @JavascriptInterface + fun setPullToRefreshEnabled(enable: Boolean?) { + Log.d(TAG, "setPullToRefreshEnabled: enable: $enable") + } + + @JavascriptInterface + fun setShadowVisible(visible: Boolean?) { + Log.d(TAG, "setShadowVisible: visible: $visible") + } + + @JavascriptInterface + fun setStyle(style: Int?) { + Log.d(TAG, "setStyle: style: $style") + } + + @JavascriptInterface + fun setTitleFontFamily(family: Int?) { + Log.d(TAG, "setTitleFontFamily: family: $family") + } + + @JavascriptInterface + fun setTitleText(title: String?) { + Log.d(TAG, "setTitleText: title: $title") + runOnMainLooper { + val text = if (TextUtils.isEmpty(title)) { + ContextCompat.getString(toolBar.context, R.string.pref_accounts_title) + } else title + toolBar.setCustomTitleText(text) + } + } + + @JavascriptInterface + fun setTitleType(type: Int?) { + Log.d(TAG, "setTitleType: type: $type") + } + + @JavascriptInterface + fun setUpButtonAction(action: Int?) { + Log.d(TAG, "setUpButtonAction: action: $action") + } + + @JavascriptInterface + fun setUpButtonVisible(visible: Boolean?) { + Log.d(TAG, "setUpButtonVisible: visible: $visible") + } + + private fun Toolbar.setCustomTitleText(text: String?) { + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child is TextView) { + child.text = text + break + } + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAppPermissionsBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAppPermissionsBridge.kt new file mode 100644 index 0000000000..6b3a1ac516 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcAppPermissionsBridge.kt @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import java.util.Locale + +class OcAppPermissionsBridge(val webView: WebView) { + + companion object { + const val NAME = "ocAppPermissions" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun getAppPermissionsData(eventId: Int?) { + Log.d(TAG, "getAppPermissionsData: eventId: $eventId") + ocAppPermissionsCallbackError(eventId) + } + + @JavascriptInterface + fun getSupportedPermissionsDescription(eventId: Int?) { + Log.d(TAG, "getSupportedPermissionsDescription: eventId: $eventId") + ocAppPermissionsCallbackError(eventId) + } + + private fun ocAppPermissionsCallbackError(eventId: Int?) { + val format = String.format(Locale.ROOT, "window.ocAppPermissionsCallback(%s, %s, %s)", eventId, null, true) + evaluateJavascriptCallback(webView, format) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcClientInfoBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcClientInfoBridge.kt new file mode 100644 index 0000000000..f3262cfdfe --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcClientInfoBridge.kt @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import org.microg.gms.profile.Build +import android.util.Log +import android.webkit.JavascriptInterface +import org.microg.gms.common.Constants + +class OcClientInfoBridge() { + + companion object { + const val NAME = "ocClientInfo" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun getGmsCoreModuleApkVersionName(): String? { + Log.d(TAG, "getGmsCoreModuleApkVersionName: ") + return null + } + + @JavascriptInterface + fun getGmsCoreModuleVersion(): Int { + Log.d(TAG, "getGmsCoreModuleVersion: ") + return 0 + } + + @JavascriptInterface + fun getGmsCoreVersion(): Int { + Log.d(TAG, "getGmsCoreVersion: ") + return Constants.GMS_VERSION_CODE + } + + @JavascriptInterface + fun getOsVersion(): String? { + Log.d(TAG, "getOsVersion: ") + return Build.VERSION.RELEASE + } + + @JavascriptInterface + fun getSdkVersion(): Int { + Log.d(TAG, "getSdkVersion: ") + return Build.VERSION.SDK_INT + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcConsistencyBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcConsistencyBridge.kt new file mode 100644 index 0000000000..84e4104517 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcConsistencyBridge.kt @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface + +class OcConsistencyBridge() { + + companion object { + const val NAME = "ocConsistency" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun accountWasDeleted() { + Log.d(TAG, "accountWasDeleted: ") + } + + @JavascriptInterface + fun accountWasRenamed() { + Log.d(TAG, "accountWasRenamed: ") + } + + @JavascriptInterface + fun verifyActualAccountId(accountId: String?) { + Log.d(TAG, "verifyActualAccountId: accountId: $accountId") + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcContactsBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcContactsBridge.kt new file mode 100644 index 0000000000..04a26a995a --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcContactsBridge.kt @@ -0,0 +1,27 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import java.util.Locale + +class OcContactsBridge(val webView: WebView) { + + companion object { + const val NAME = "ocContacts" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun readContacts() { + Log.d(TAG, "readContacts: ") + val format = String.format(Locale.ROOT, "window.ocContactsReadContactsCallback(%s, %s)", null, true) + evaluateJavascriptCallback(webView, format) + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFido2Bridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFido2Bridge.kt new file mode 100644 index 0000000000..038b1b4d6d --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFido2Bridge.kt @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import java.util.Locale + +class OcFido2Bridge(val webView: WebView) { + + companion object { + const val NAME = "ocFido2" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun startBuiltInAuthenticatorAssertionRequest(json: String?) { + Log.d(TAG, "startBuiltInAuthenticatorAssertionRequest: json: $json") + val format = String.format(Locale.ROOT, "window.ocFido2BuiltInAuthenticatorAssertionResponse(%s)", null) + evaluateJavascriptCallback(webView, format) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFidoU2fBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFidoU2fBridge.kt new file mode 100644 index 0000000000..bd776e0a76 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFidoU2fBridge.kt @@ -0,0 +1,32 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import java.util.Locale + +class OcFidoU2fBridge(val webView: WebView) { + + companion object { + const val NAME = "mm" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun sendSkUiEvent(eventJsonStr: String?) { + Log.d(TAG, "sendSkUiEvent: eventJsonStr: $eventJsonStr") + } + + @JavascriptInterface + fun startSecurityKeyAssertionRequest(requestJsonStr: String?) { + Log.d(TAG, "startSecurityKeyAssertionRequest: requestJsonStr: $requestJsonStr") + val format = String.format(Locale.ROOT, "window.setSkResult(%s);", null) + evaluateJavascriptCallback(webView, format) + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFilePickerBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFilePickerBridge.kt new file mode 100644 index 0000000000..b9cf11dd42 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFilePickerBridge.kt @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.net.Uri +import android.util.Base64 +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.MainActivity +import org.microg.gms.accountsettings.ui.PicturePickerUtils +import org.microg.gms.accountsettings.ui.ResultStatus +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import org.microg.gms.accountsettings.ui.runOnMainLooper +import java.lang.RuntimeException +import java.util.concurrent.ExecutorService + +class OcFilePickerBridge(val activity: MainActivity, val webView: WebView, val executor: ExecutorService) { + + companion object { + const val NAME = "ocFilePicker" + private const val TAG = "JS_$NAME" + } + + private var currentRequestId: Int = 0 + private var pendingRequestId: Int? = null + private var lastResult: Triple? = null + + private val pickerUtils = PicturePickerUtils(activity, ::handleResult, ::handleError) + + @JavascriptInterface + fun pick(requestId: Int, mimeType: String?) { + Log.d(TAG, "pick: requestId = $requestId, mimeType = $mimeType") + currentRequestId = requestId + val type = mimeType ?: "*/*" + + runOnMainLooper { + try { + pickerUtils.launchChooser(type) + } catch (e: Exception) { + Log.w(TAG, "pick: launchChooser error", e) + notifyJavascript(requestId, ResultStatus.FAILED.value, "", "") + } + } + } + + @JavascriptInterface + fun resume(requestId: Int) { + Log.d(TAG, "resume: requestId: $requestId lastResult:$lastResult") + val lastResult = this.lastResult + + runOnMainLooper { + if (lastResult != null) { + val (status, mimeType, data) = lastResult + notifyJavascript(requestId, status, mimeType ?: "", data ?: "") + this.lastResult = null + } else if (pendingRequestId != null) { + pendingRequestId = requestId + } else { + notifyJavascript(requestId, ResultStatus.NO_OP.value, "", "") + } + } + } + + private fun handleResult(uri: Uri?) { + if (uri == null) { + notifyJavascript(currentRequestId, ResultStatus.USER_CANCEL.value, "", "") + return + } + pendingRequestId = currentRequestId + executor.submit { + try { + val contentResolver = activity.contentResolver + val mimeType = contentResolver.getType(uri) ?: "image/jpeg" + val inputStream = contentResolver.openInputStream(uri) + + if (inputStream != null) { + val bytes = inputStream.readBytes() + val encodedData = Base64.encodeToString(bytes, Base64.NO_WRAP) + inputStream.close() + + runOnMainLooper { + val pendingId = pendingRequestId + if (pendingId != null) { + notifyJavascript(pendingId, ResultStatus.SUCCESS.value, mimeType, encodedData) + pendingRequestId = null + } else { + lastResult = Triple(ResultStatus.SUCCESS.value, mimeType, encodedData) + } + } + } else { + throw RuntimeException("Failed to open input stream") + } + } catch (e: Exception) { + Log.w(TAG, "handleResult: ", e) + runOnMainLooper { + val pendingId = pendingRequestId + if (pendingId != null) { + notifyJavascript(pendingId, ResultStatus.FAILED.value, "", "") + pendingRequestId = null + } + } + } + } + } + + private fun handleError(status: ResultStatus) { + notifyJavascript(currentRequestId, status.value, "", "") + } + + private fun notifyJavascript(requestId: Int, status: Int, mimeType: String, data: String) { + Log.d(TAG, "notifyJavascript: requestId: $requestId status: $status mimeType: $mimeType, data: $data") + val escapedData = data.replace("\\", "\\\\").replace("'", "\\'") + val script = "window.ocFilePickerCallback($requestId, $status, '$mimeType', '$escapedData')" + evaluateJavascriptCallback(webView, script) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFolsomBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFolsomBridge.kt new file mode 100644 index 0000000000..78679ebb3e --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcFolsomBridge.kt @@ -0,0 +1,23 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface + +class OcFolsomBridge() { + + companion object { + const val NAME = "ocFolsom" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun addEncryptionRecoveryMethod(key: String?, jsonArray: String?, jsonObject: String?, eventId: Int?) { + Log.d(TAG, "addEncryptionRecoveryMethod: key: $key, jsonArray: $jsonArray, jsonObject: $jsonObject, eventId: $eventId") + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcPermissionsBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcPermissionsBridge.kt new file mode 100644 index 0000000000..83dc9ce246 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcPermissionsBridge.kt @@ -0,0 +1,34 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import java.util.Locale + +class OcPermissionsBridge(val webView: WebView) { + + companion object { + const val NAME = "ocPermissions" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun checkPermissions(permissionBase64: String?): String? { + Log.d(TAG, "checkPermissions: permissionBase64: $permissionBase64") + return null + } + + @JavascriptInterface + fun ensurePermissions(permissionBase64: String?) { + Log.d(TAG, "ensurePermissions: permissionBase64: $permissionBase64") + val format = String.format(Locale.ROOT, "window.ocPermissionsEnsurePermissionsCallback(%s, %s)", null, true) + evaluateJavascriptCallback(webView, format) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcPlayProtectBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcPlayProtectBridge.kt new file mode 100644 index 0000000000..1088bdd33f --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcPlayProtectBridge.kt @@ -0,0 +1,72 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import java.util.Locale + +class OcPlayProtectBridge(val webView: WebView) { + + companion object { + const val NAME = "ocPlayProtect" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun enablePlayProtect(protectId: Int?) { + Log.d(TAG, "enablePlayProtect: protectId: $protectId") + ocPlayProtectCallback(protectId) + } + + @JavascriptInterface + fun getHarmfulAppsCount(protectId: Int?) { + Log.d(TAG, "getHarmfulAppsCount: protectId: $protectId") + ocPlayProtectCallback(protectId) + } + + @JavascriptInterface + fun getLastScanTimeMs(protectId: Int?) { + Log.d(TAG, "getLastScanTimeMs: protectId: $protectId") + ocPlayProtectCallback(protectId) + } + + @JavascriptInterface + fun isPlayProtectEnabled(protectId: Int?) { + Log.d(TAG, "isPlayProtectEnabled: protectId: $protectId") + ocPlayProtectCallback(protectId) + } + + @JavascriptInterface + fun isPlayStoreVersionValid(protectId: Int?) { + Log.d(TAG, "isPlayStoreVersionValid: protectId: $protectId") + ocPlayProtectCallback(protectId, true) + } + + @JavascriptInterface + fun startPlayProtectActivity(protectId: Int?) { + Log.d(TAG, "startPlayProtectActivity: protectId: $protectId") + ocPlayProtectCallbackV2(protectId) + } + + private fun ocPlayProtectCallback(protectId: Int?, valid: Boolean) { + val format = String.format(Locale.ROOT, "window.ocPlayProtectCallback(%s, %s)", protectId, valid) + evaluateJavascriptCallback(webView, format) + } + + private fun ocPlayProtectCallback(protectId: Int?) { + val format = String.format(Locale.ROOT, "window.ocPlayProtectCallback(%s, %s, %s)", protectId, null, true) + evaluateJavascriptCallback(webView, format) + } + + private fun ocPlayProtectCallbackV2(protectId: Int?) { + val format = String.format(Locale.ROOT, "window.ocPlayProtectCallback(%s)", protectId) + evaluateJavascriptCallback(webView, format) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcTelephonyBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcTelephonyBridge.kt new file mode 100644 index 0000000000..36fb32bd37 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcTelephonyBridge.kt @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface + +class OcTelephonyBridge() { + + companion object { + const val NAME = "ocTelephony" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun getPhoneNumber(): String? { + Log.d(TAG, "getPhoneNumber: ") + return null + } + + @JavascriptInterface + fun getSimCountryIso(): String? { + Log.d(TAG, "getSimCountryIso: ") + return null + } + + @JavascriptInterface + fun getSimState(): Int { + Log.d(TAG, "getSimState: ") + return 0 + } + + @JavascriptInterface + fun hasPhoneNumber(): Boolean { + Log.d(TAG, "hasPhoneNumber: ") + return false + } + + @JavascriptInterface + fun hasTelephony(): Boolean { + Log.d(TAG, "hasTelephony: ") + return false + } + + @JavascriptInterface + fun listenForSmsCodes() { + Log.d(TAG, "listenForSmsCodes: ") + } + + @JavascriptInterface + fun sendSms(type: Int, contentBase64: String) { + Log.d(TAG, "sendSms: type: $type, contentBase64: $contentBase64") + } + + @JavascriptInterface + fun sendSmsSupportedByBridge(): Boolean { + Log.d(TAG, "sendSmsSupportedByBridge: ") + return false + } + + @JavascriptInterface + fun stopListeningForSmsCodes() { + Log.d(TAG, "stopListeningForSmsCodes: ") + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcTrustAgentBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcTrustAgentBridge.kt new file mode 100644 index 0000000000..8d5745029e --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcTrustAgentBridge.kt @@ -0,0 +1,72 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import java.util.Locale + +class OcTrustAgentBridge(val webView: WebView) { + + companion object { + const val NAME = "ocTrustAgent" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun isScreenLockSet(agentId: Int?) { + Log.d(TAG, "isScreenLockSet: agentId: $agentId") + ocTrustAgentCallback(agentId, false) + } + + @JavascriptInterface + fun isSmartLockSet(agentId: Int?) { + Log.d(TAG, "isSmartLockSet: agentId: $agentId") + ocTrustAgentCallback(agentId) + } + + @JavascriptInterface + fun isSmartLockSupported(agentId: Int?) { + Log.d(TAG, "isSmartLockSupported: agentId: $agentId") + ocTrustAgentCallback(agentId, false) + } + + @JavascriptInterface + fun isTrustletSet(config: String?, agentId: Int?) { + Log.d(TAG, "isTrustletSet: agentId: $agentId") + ocTrustAgentCallback(agentId) + } + + @JavascriptInterface + fun isTrustletSupported(agentId: Int?) { + Log.d(TAG, "isTrustletSupported: agentId: $agentId") + ocTrustAgentCallback(agentId) + } + + @JavascriptInterface + fun startScreenLockSmartLockFlow(agentId: Int?) { + Log.d(TAG, "startScreenLockSmartLockFlow: agentId: $agentId") + ocTrustAgentCallbackV2(agentId) + } + + private fun ocTrustAgentCallback(agentId: Int?, valid: Boolean) { + val format = String.format(Locale.ROOT, "window.ocTrustAgentCallback(%s, %s)", agentId, valid) + evaluateJavascriptCallback(webView, format) + } + + private fun ocTrustAgentCallback(agentId: Int?) { + val format = String.format(Locale.ROOT, "window.ocTrustAgentCallback(%s, %s, %s)", agentId, false, true) + evaluateJavascriptCallback(webView, format) + } + + private fun ocTrustAgentCallbackV2(agentId: Int?) { + val format = String.format(Locale.ROOT, "window.ocTrustAgentCallback(%s)", agentId) + evaluateJavascriptCallback(webView, format) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcUdcBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcUdcBridge.kt new file mode 100644 index 0000000000..0207c61f7a --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcUdcBridge.kt @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.accounts.Account +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.microg.gms.accountsettings.ui.evaluateJavascriptCallback +import java.util.Locale + +class OcUdcBridge(val webView: WebView) { + + companion object { + const val NAME = "ocUdc" + private const val TAG = "JS_$NAME" + } + + @JavascriptInterface + fun canGetUlrDeviceInformation(): Boolean { + Log.d(TAG, "canGetUlrDeviceInformation: ") + return false + } + + @JavascriptInterface + fun canOpenUlrSettingsUi(account: Account?): Boolean { + Log.d(TAG, "canOpenUlrSettingsUi: account: ${account?.name}") + return account != null + } + + @JavascriptInterface + fun getDeviceSettingsStates(iArray: IntArray, eventId: Int?) { + Log.d(TAG, "getDeviceSettingsStates: eventId: $eventId") + ocUdcCallbackError(eventId) + } + + @JavascriptInterface + fun getSupportedDeviceSettings(eventId: Int?) { + Log.d(TAG, "getSupportedDeviceSettings: eventId: $eventId") + ocUdcCallbackError(eventId) + } + + @JavascriptInterface + fun getUlrDeviceInformation(eventId: Int?) { + Log.d(TAG, "getUlrDeviceInformation: eventId: $eventId") + } + + @JavascriptInterface + fun openUlrSettingsUi(): Boolean { + Log.d(TAG, "openUlrSettingsUi: ") + return false + } + + @JavascriptInterface + fun setDeviceSetting(settingId: Int?, flag: Boolean?, eventId: Int?) { + Log.d(TAG, "setDeviceSetting: settingId: $settingId, flag: $flag, eventId: $eventId") + ocUdcCallbackError(eventId) + } + + private fun ocUdcCallbackError(eventId: Int?) { + val format = String.format(Locale.ROOT, "window.ocUdcCallback(%s, %s, %s)", eventId, null, true) + evaluateJavascriptCallback(webView, format) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcUiBridge.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcUiBridge.kt new file mode 100644 index 0000000000..b2567f0958 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/bridge/OcUiBridge.kt @@ -0,0 +1,142 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.accountsettings.ui.bridge + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.json.JSONException +import org.json.JSONObject +import org.microg.gms.accountsettings.ui.EXTRA_ACCOUNT_NAME +import org.microg.gms.accountsettings.ui.EXTRA_SCREEN_ID +import org.microg.gms.accountsettings.ui.KEY_UPDATED_PHOTO_URL +import org.microg.gms.accountsettings.ui.MainActivity + +class OcUiBridge(val activity: MainActivity, val accountName:String?, val webView: WebView?) { + + companion object{ + const val NAME = "ocUi" + private const val TAG = "JS_$NAME" + } + + private var resultBundle: Bundle? = null + + @JavascriptInterface + fun close() { + Log.d(TAG, "close: ") + val intent = Intent() + if (resultBundle != null) { + intent.putExtras(resultBundle!!) + } + activity.setResult(RESULT_OK, intent) + activity.finish() + } + + @JavascriptInterface + fun closeWithResult(resultJsonStr: String?) { + Log.d(TAG, "closeWithResult: resultJsonStr -> $resultJsonStr") + setResult(resultJsonStr) + close() + } + + @JavascriptInterface + fun goBackOrClose() { + Log.d(TAG, "goBackOrClose: ") + activity.onBackPressed() + } + + @JavascriptInterface + fun hideKeyboard() { + Log.d(TAG, "hideKeyboard: ") + } + + @JavascriptInterface + fun isCloseWithResultSupported(): Boolean { + Log.d(TAG, "isCloseWithResultSupported: ") + return true + } + + @JavascriptInterface + fun isOpenHelpEnabled(): Boolean { + Log.d(TAG, "isOpenHelpEnabled: ") + return true + } + + @JavascriptInterface + fun isOpenScreenEnabled(): Boolean { + Log.d(TAG, "isOpenScreenEnabled: ") + return true + } + + @JavascriptInterface + fun isSetResultSupported(): Boolean { + Log.d(TAG, "isSetResultSupported: ") + return true + } + + @JavascriptInterface + fun open(str: String?) { + Log.d(TAG, "open: str -> $str") + } + + @JavascriptInterface + fun openHelp(str: String?) { + Log.d(TAG, "openHelp: str -> $str") + } + + @JavascriptInterface + fun openScreen(screenId: Int, str: String?) { + Log.d(TAG, "openScreen: screenId -> $screenId str -> $str accountName -> $accountName") + val intent = Intent(activity, MainActivity::class.java).apply { + putExtra(EXTRA_SCREEN_ID, screenId) + putExtra(EXTRA_ACCOUNT_NAME, accountName) + } + activity.startActivity(intent) + } + + @JavascriptInterface + fun setBackStop() { + Log.d(TAG, "setBackStop: ") + webView?.clearHistory() + } + + @JavascriptInterface + fun setResult(resultJsonStr: String?) { + Log.d(TAG, "setResult: resultJsonStr -> $resultJsonStr") + val map = jsonToMap(resultJsonStr) ?: return + if (map.containsKey(KEY_UPDATED_PHOTO_URL)) { + activity.updateLocalAccountAvatar(map[KEY_UPDATED_PHOTO_URL], accountName) + } + resultBundle = Bundle().apply { + for ((key, value) in map) { + putString("result.$key", value) + } + } + } + + private fun jsonToMap(jsonStr: String?): Map? { + val hashMap = HashMap() + if (!jsonStr.isNullOrEmpty()) { + try { + val jSONObject = JSONObject(jsonStr) + val keys = jSONObject.keys() + while (keys.hasNext()) { + val next = keys.next() + val obj = jSONObject[next] + hashMap[next] = obj as String + } + } catch (e: JSONException) { + Log.d(TAG, "Unable to parse result JSON string", e) + return null + } + } + return hashMap + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/extensions.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/extensions.kt index 7947944786..211a61131b 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/extensions.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/extensions.kt @@ -5,6 +5,11 @@ package org.microg.gms.accountsettings.ui +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.webkit.WebView + const val ACTION_BROWSE_SETTINGS = "com.google.android.gms.accountsettings.action.BROWSE_SETTINGS" const val ACTION_MY_ACCOUNT = "com.google.android.gms.accountsettings.MY_ACCOUNT" const val ACTION_ACCOUNT_PREFERENCES_SETTINGS = "com.google.android.gms.accountsettings.ACCOUNT_PREFERENCES_SETTINGS" @@ -25,4 +30,37 @@ const val EXTRA_SCREEN_KID_ONBOARDING_PARAMS = "extra.screen.kidOnboardingParams const val KEY_UPDATED_PHOTO_URL = "updatedPhotoUrl" -const val OPTION_SCREEN_FLAVOR = "screenFlavor" \ No newline at end of file +const val OPTION_SCREEN_FLAVOR = "screenFlavor" + +enum class ResultStatus(val value: Int) { + USER_CANCEL(1), FAILED(2), SUCCESS(3), NO_OP(4) +} + +fun evaluateJavascriptCallback(webView: WebView, script: String) { + runOnMainLooper { + webView.evaluateJavascript(script, null) + } +} + +fun runOnMainLooper(method: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + method() + } else { + Handler(Looper.getMainLooper()).post { + method() + } + } +} + +fun isGoogleAvatarUrl(url: String?): Boolean { + if (url.isNullOrBlank()) return false + return try { + val uri = Uri.parse(url) + val isGoogleHost = uri.host == "lh3.googleusercontent.com" + val isAvatarPath = uri.path?.startsWith("/a/") == true + val hasSizeParam = url.matches(Regex(".*=s\\d+-c-no$")) + isGoogleHost && isAvatarPath && hasSizeParam + } catch (e: Exception) { + false + } +} \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_arrow_close.xml b/play-services-core/src/main/res/drawable/ic_arrow_close.xml new file mode 100644 index 0000000000..51acf8fa9f --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_arrow_close.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/xml/file_provider_paths.xml b/play-services-core/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 0000000000..cf9fe6d9f6 --- /dev/null +++ b/play-services-core/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file