diff --git a/play-services-base/core/src/main/java/org/microg/gms/DummyService.java b/play-services-base/core/src/main/java/org/microg/gms/DummyService.java index 3f178f5ead..8c489e2262 100644 --- a/play-services-base/core/src/main/java/org/microg/gms/DummyService.java +++ b/play-services-base/core/src/main/java/org/microg/gms/DummyService.java @@ -17,6 +17,7 @@ package org.microg.gms; import android.os.RemoteException; +import android.util.Log; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.api.CommonStatusCodes; @@ -32,6 +33,7 @@ public DummyService() { @Override public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException { + Log.w("GmsDummySvc", "Service not implemented: " + service + " requested by " + request.packageName); callback.onPostInitComplete(ConnectionResult.API_DISABLED, null, null); } } diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle index 4d25419ecd..81b5682c64 100644 --- a/play-services-core/build.gradle +++ b/play-services-core/build.gradle @@ -70,6 +70,7 @@ dependencies { implementation project(':play-services-safetynet') implementation project(':play-services-tasks-ktx') implementation project(':play-services-fitness') + implementation project(':play-services-wearable') mapboxRuntimeOnly project(':play-services-maps-core-mapbox') vtmRuntimeOnly project(':play-services-maps-core-vtm') diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 7d81af916e..aa4029e22b 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -460,6 +460,7 @@ diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt b/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt index 83246ba405..42dce2a900 100644 --- a/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt +++ b/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt @@ -5,14 +5,293 @@ package com.google.android.gms.wearable.consent +import android.graphics.Typeface import android.os.Bundle +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat +import androidx.core.widget.NestedScrollView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.gms.R +import com.google.android.gms.common.api.CommonStatusCodes +import com.google.android.gms.common.api.Status +import com.google.android.gms.common.api.internal.ConnectionCallbacks +import com.google.android.gms.common.api.internal.OnConnectionFailedListener +import com.google.android.gms.wearable.internal.AcceptTermsRequest +import com.google.android.material.button.MaterialButton +import org.microg.gms.wearable.BaseWearableCallbacks +import org.microg.gms.wearable.WearableClientImpl +import org.microg.gms.wearable.WearableTerms +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + + class TermsOfServiceActivity : AppCompatActivity() { + private lateinit var terms: List + private val checked = HashSet() + + private var termsContext = WearableTerms.CONTEXT_UNSUPERVISED + private var watchNodeId: String? = null + private var accountName: String? = null + private var perWatchConsents = false + private var showBackup = false + private var isLeDevice = false + + private lateinit var scroll: NestedScrollView + private lateinit var buttonBar: View + private var acceptButton: MaterialButton? = null + private var declineButton: MaterialButton? = null + + private var atBottom = false + private var submitting = false + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setResult(RESULT_CANCELED) - finish() + + termsContext = intent.getIntExtra(EXTRA_TERMS_CONTEXT, WearableTerms.CONTEXT_UNSUPERVISED) + watchNodeId = intent.getStringExtra(EXTRA_WATCH_PEER_ID) + accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME) + perWatchConsents = intent.getBooleanExtra(EXTRA_USE_CONSENT_PER_WATCH, false) + showBackup = intent.getBooleanExtra(EXTRA_SHOW_BACKUP_CONSENT, false) + isLeDevice = intent.getBooleanExtra(EXTRA_IS_LE_DEVICE, false) + + if (termsContext != WearableTerms.CONTEXT_UNSUPERVISED) { + Log.w(TAG, "Unsupported terms context $termsContext") + finish() + return + } + + terms = WearableTerms.forContext(termsContext) + ?.filter { it.termType != WearableTerms.CLOUDSYNC && (it.termType != WearableTerms.BACKUP || showBackup) } + ?: run { finish(); return } + + savedInstanceState?.getIntArray(STATE_CHECKED)?.let { checked.addAll(it.toList()) } + + setContentView(R.layout.activity_wearable_tos) + + acceptButton = findViewById(R.id.terms_of_service_accept_button) + declineButton = findViewById(R.id.terms_of_service_decline_button) + scroll = findViewById(R.id.terms_of_service_scroll_container) + buttonBar = acceptButton?.parent as View + + bindHeader() + + findViewById(R.id.terms_of_service_list).apply { + layoutManager = LinearLayoutManager(this@TermsOfServiceActivity) + isNestedScrollingEnabled = false + adapter = TermsAdapter(terms.filter { it.termType != WearableTerms.TOS }) + } + + buttonBar.setBackgroundColor(themeColor(android.R.attr.colorBackground)) + scroll.clipToPadding = true + scroll.viewTreeObserver.addOnGlobalLayoutListener { + if (scroll.paddingBottom != buttonBar.height) { + scroll.setPadding(0, 0, 0, buttonBar.height) + } + updateAcceptButton() + } + scroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, _, _, _ -> updateAcceptButton() }) + acceptButton?.setOnClickListener { + if (!atBottom) scroll.fullScroll(View.FOCUS_DOWN) else submit() + } + declineButton?.setOnClickListener { + setResult(RESULT_CANCELED) + finish() + } + + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putIntArray(STATE_CHECKED, checked.toIntArray()) + } + + private fun bindHeader() { + val pad = dp(24) + findViewById(R.id.header_image).visibility = View.GONE + findViewById(R.id.header).setPadding(pad, pad, pad, 0) + val tos = terms.firstOrNull { it.termType == WearableTerms.TOS } + findViewById(R.id.terms_of_service_title).apply { + tos?.getTitle(this@TermsOfServiceActivity)?.let { text = it } + setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f) + } + findViewById(R.id.terms_of_service_subtitle).apply { + setPadding(pad, dp(8), pad, dp(8)) + text = tos?.getDescription(this@TermsOfServiceActivity) + visibility = if (text.isNullOrEmpty()) View.GONE else View.VISIBLE + } + } + + private fun updateAcceptButton() { + atBottom = !scroll.canScrollVertically(1) + acceptButton?.setText(if (atBottom) R.string.wearable_tos_accept else R.string.wearable_tos_more) + } + + private fun buildRequest(): AcceptTermsRequest { + val accepted = ArrayList() + for (term in terms) { + if (!term.explicit || term.termType in checked) accepted.add(term.termType) + } + if (isLeDevice) accepted.add(WearableTerms.CLOUDSYNC) + val skipped = if (showBackup && WearableTerms.BACKUP !in checked) listOf(WearableTerms.BACKUP) else null + return AcceptTermsRequest( + termsContext, accepted, null, null, + watchNodeId, accountName, skipped, perWatchConsents + ) + } + + private fun submit() { + if (submitting) return + submitting = true + acceptButton?.isEnabled = false + declineButton?.isEnabled = false + val request = buildRequest() + Thread { + val status = try { + acceptTermsBlocking(request) + } catch (e: Exception) { + Log.w(TAG, "acceptTerms failed", e) + CommonStatusCodes.ERROR + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + if (status == CommonStatusCodes.SUCCESS) { + setResult(RESULT_OK) + finish() + } else { + submitting = false + acceptButton?.isEnabled = true + declineButton?.isEnabled = true + } + } + }.start() + } + + private fun acceptTermsBlocking(request: AcceptTermsRequest): Int { + val connected = CountDownLatch(1) + val client = WearableClientImpl( + this, null, + object : ConnectionCallbacks { + override fun onConnected(connectionHint: Bundle?) = connected.countDown() + override fun onConnectionSuspended(cause: Int) = Unit + }, + OnConnectionFailedListener { + Log.w(TAG, "Connection failed: $it") + connected.countDown() + } + ) + try { + client.connect() + if (!connected.await(10, TimeUnit.SECONDS) || !client.isConnected) { + Log.w(TAG, "Could not connect to the wearable service") + return CommonStatusCodes.ERROR + } + val code = AtomicInteger(CommonStatusCodes.ERROR) + val latch = CountDownLatch(1) + client.serviceInterface.acceptTerms(object : BaseWearableCallbacks() { + override fun onStatus(status: Status) { + code.set(status.statusCode) + latch.countDown() + } + }, request) + + if (!latch.await(30, TimeUnit.SECONDS)) + return CommonStatusCodes.ERROR + + return code.get() + } finally { + client.disconnect() + } + } + + private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() + + private fun themeColor(attr: Int): Int { + val value = TypedValue() + theme.resolveAttribute(attr, value, true) + return value.data + } + + private inner class TermsAdapter(private val items: List) : + RecyclerView.Adapter() { + + inner class Holder( + row: View, + val title: TextView, + val description: TextView, + val toggle: SwitchCompat + ) : RecyclerView.ViewHolder(row) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + val ctx = parent.context + val title = TextView(ctx).apply { + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + setTypeface(typeface, Typeface.BOLD) + } + val description = TextView(ctx).apply { setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) } + val texts = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + addView(title) + addView(description) + } + val toggle = SwitchCompat(ctx).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { marginStart = dp(16) } + } + val row = LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(24), dp(12), dp(24), dp(12)) + layoutParams = RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + addView(texts) + addView(toggle) + } + return Holder(row, title, description, toggle) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: Holder, position: Int) { + val term = items[position] + val title = term.getTitle(this@TermsOfServiceActivity) + holder.title.text = title + holder.title.visibility = if (title.isNullOrEmpty()) View.GONE else View.VISIBLE + holder.description.text = term.getDescription(this@TermsOfServiceActivity) + holder.toggle.setOnCheckedChangeListener(null) + holder.toggle.visibility = if (term.explicit) View.VISIBLE else View.GONE + holder.toggle.isChecked = term.termType in checked + holder.toggle.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) checked.add(term.termType) else checked.remove(term.termType) + } + } + } + + + companion object { + private const val TAG = "TermsOfService" + private const val STATE_CHECKED = "checked_terms" + private const val EXTRA_TERMS_CONTEXT = "terms_context" + private const val EXTRA_WATCH_PEER_ID = "watch_peer_id" + private const val EXTRA_USE_CONSENT_PER_WATCH = "use_consent_per_watch" + private const val EXTRA_ACCOUNT_NAME = "account_name" + private const val EXTRA_SHOW_BACKUP_CONSENT = "show_backup_consent" + private const val EXTRA_IS_LE_DEVICE = "is_le_device" } } \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt index 70335535ce..84b28f7292 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt @@ -54,6 +54,10 @@ class SettingsFragment : ResourceSettingsFragment() { findNavController().navigate(requireContext(), R.id.openWorkProfileSettings) true } + findPreference(PREF_WEAR)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { + findNavController().navigate(requireContext(), R.id.openWearSettings) + true + } findPreference(PREF_ABOUT)!!.apply { onPreferenceClickListener = Preference.OnPreferenceClickListener { @@ -138,6 +142,7 @@ class SettingsFragment : ResourceSettingsFragment() { const val PREF_CHECKIN = "pref_checkin" const val PREF_VENDING = "pref_vending" const val PREF_WORK_PROFILE = "pref_work_profile" + const val PREF_WEAR = "pref_wear" const val PREF_ACCOUNTS = "pref_accounts" } diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/WearConfigFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearConfigFragment.kt new file mode 100644 index 0000000000..aecbbf106d --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearConfigFragment.kt @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.Log +import androidx.lifecycle.lifecycleScope +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import com.google.android.gms.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.microg.gms.wearable.ConfigurationDatabaseHelper + +class WearConfigFragment : PreferenceFragmentCompat() { + + private lateinit var appHeadingPreference: AppHeadingPreference + private lateinit var configNamePreference: EditTextPreference + private lateinit var configEnabledPreference: SwitchPreferenceCompat + private lateinit var configAddressPreference: EditTextPreference + private lateinit var configPackageNamePreference: EditTextPreference + private lateinit var configDeletePreference: Preference + + private lateinit var database: ConfigurationDatabaseHelper + private val configName: String? + get() = arguments?.getString("name") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + database = ConfigurationDatabaseHelper(context) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_wear_config) + } + + @SuppressLint("RestrictedApi") + override fun onBindPreferences() { + appHeadingPreference = + preferenceScreen.findPreference("pref_wear_config_heading") ?: appHeadingPreference + configNamePreference = + preferenceScreen.findPreference("pref_wear_config_name") ?: configNamePreference + configEnabledPreference = + preferenceScreen.findPreference("pref_wear_config_enabled") ?: configEnabledPreference + configAddressPreference = + preferenceScreen.findPreference("pref_wear_config_address") ?: configAddressPreference + configPackageNamePreference = + preferenceScreen.findPreference("pref_wear_config_package_name") + ?: configPackageNamePreference + configDeletePreference = preferenceScreen.findPreference("pref_wear_config_delete") + ?: configDeletePreference + + configEnabledPreference.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + val config = database.getConfiguration(configName) + config.enabled = newValue as Boolean + database.putConfiguration(config) + database.close() + true + } + + configAddressPreference.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + val config = database.getConfiguration(configName) + config.address = newValue as String + database.putConfiguration(config) + database.close() + true + } + + configPackageNamePreference.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + Log.d(TAG, "PackageName changed: $newValue") + val config = database.getConfiguration(configName) + config.packageName = newValue as String + database.putConfiguration(config) + database.close() + true + } + + configDeletePreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + showDeleteConfirm() + true + } + } + + private fun showDeleteConfirm() { + requireContext().buildAlertDialog() + .setTitle(getString(R.string.wear_delete_confirm_title, configName)) + .setPositiveButton(android.R.string.yes) { _, _ -> delete() } + .setNegativeButton(android.R.string.no) { _, _ -> } + .show() + } + + private fun delete() { + lifecycleScope.launchWhenResumed { + withContext(Dispatchers.IO) { + database.deleteConfiguration(configName) + database.close() + // TODO: Leave fragment + } + } + } + + override fun onResume() { + super.onResume() + updateContent() + } + + override fun onPause() { + super.onPause() + database.close() + } + + private fun updateContent() { + lifecycleScope.launchWhenResumed { + configNamePreference.text = configName + val config = + configName?.let { database.getConfiguration(it) } ?: return@launchWhenResumed + appHeadingPreference.packageName = config.packageName + configEnabledPreference.isChecked = config.enabled + configAddressPreference.text = config.address + configPackageNamePreference.text = config.packageName + + database.close() + } + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/WearConfigPreference.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearConfigPreference.kt new file mode 100644 index 0000000000..67ae71f0c0 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearConfigPreference.kt @@ -0,0 +1,61 @@ +package org.microg.gms.ui + +import android.content.Context +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.widget.ImageView +import androidx.appcompat.content.res.AppCompatResources +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.google.android.gms.wearable.ConnectionConfiguration + +class WearConfigPreference: Preference { + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context) : super(context) + + init { + isPersistent = false + } + + private var configField: ConnectionConfiguration? = null + + var connectionConfig: ConnectionConfiguration? + get() = configField + set(value) { + if (value == null && configField != null) { + title = null + icon = null + } else if (value != null) { + val pm = context.packageManager + val applicationInfo = pm.getApplicationInfoIfExists(value.packageName) + + title = value.name + summary = value.packageName + icon = applicationInfo?.loadIcon(pm) ?: AppCompatResources.getDrawable(context, android.R.mipmap.sym_def_app_icon) + } + configField = value + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val icon = holder.findViewById(android.R.id.icon) + if (icon is ImageView) { + icon.adjustViewBounds = true + icon.scaleType = ImageView.ScaleType.CENTER_INSIDE + icon.maxHeight = (32.0 * context.resources.displayMetrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT).toInt() + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/WearFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearFragment.kt new file mode 100644 index 0000000000..03295e4247 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearFragment.kt @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import com.google.android.gms.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.microg.gms.wearable.ConfigurationDatabaseHelper + +class WearFragment : PreferenceFragmentCompat() { + + private lateinit var wearConnections: PreferenceCategory + private lateinit var wearConnectionsAll: Preference + private lateinit var wearConnectionsNone: Preference + private lateinit var database: ConfigurationDatabaseHelper + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + database = ConfigurationDatabaseHelper(context) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_wear) + } + + @SuppressLint("RestrictedApi") + override fun onBindPreferences() { + wearConnections = + preferenceScreen.findPreference("prefcat_wear_connections") ?: wearConnections + wearConnectionsAll = + preferenceScreen.findPreference("pref_wear_connections_all") ?: wearConnectionsAll + wearConnectionsNone = + preferenceScreen.findPreference("pref_wear_connections_none") ?: wearConnectionsNone + + val recreateBtn = + preferenceScreen.findPreference("pref_recreate_connection_table") + + recreateBtn?.setOnPreferenceClickListener { + + AlertDialog.Builder(requireContext()) + .setTitle(getString(R.string.pref_wear_config_delete_db)) + .setMessage(getString(R.string.pref_wear_recreate_warning_message)) + .setNegativeButton(getString(R.string.pref_wear_recreate_warning_negative), null) + .setPositiveButton(getString(R.string.pref_wear_recreate_warning_positive)) { _, _ -> + lifecycleScope.launch(Dispatchers.IO) { + database.resetConnectionConfigurations(database.writableDatabase) + + withContext(Dispatchers.Main) { + updateContent() + } + } + } + .show() + + true + } + } + + override fun onResume() { + super.onResume() + updateContent() + } + + override fun onPause() { + super.onPause() + database.close() + } + + private fun updateContent() { + lifecycleScope.launchWhenResumed { + val context = requireContext() + + val (configs, showAll) = withContext(Dispatchers.IO) { + val configs = database.allConfigurations + val res = configs.take(3).mapIndexed { idx, config -> + val pref = WearConfigPreference(context) + pref.order = idx + pref.connectionConfig = config + pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + findNavController().navigate(requireContext(), R.id.openWearConfigDetails, bundleOf( + "name" to config.name + )) + true + } + pref.key = "pref_wear_conection_" + config.packageName + pref + }.let { it to (it.size < configs.size) } + database.close() + res + } + + wearConnectionsAll.isVisible = showAll + wearConnections.removeAll() + for (config in configs) { + wearConnections.addPreference(config) + } + if (showAll) { + wearConnections.addPreference(wearConnectionsAll) + } else if (configs.isEmpty()) { + wearConnections.addPreference(wearConnectionsNone) + } + } + } +} diff --git a/play-services-core/src/main/res/drawable/ic_watch.xml b/play-services-core/src/main/res/drawable/ic_watch.xml new file mode 100644 index 0000000000..2a9da57eae --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_watch.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/play-services-core/src/main/res/layout/activity_wearable_tos.xml b/play-services-core/src/main/res/layout/activity_wearable_tos.xml new file mode 100644 index 0000000000..b1cabc1dfb --- /dev/null +++ b/play-services-core/src/main/res/layout/activity_wearable_tos.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/navigation/nav_settings.xml b/play-services-core/src/main/res/navigation/nav_settings.xml index b1b11dd439..2fde4f6fdf 100644 --- a/play-services-core/src/main/res/navigation/nav_settings.xml +++ b/play-services-core/src/main/res/navigation/nav_settings.xml @@ -29,6 +29,9 @@ + @@ -196,6 +199,26 @@ android:name="org.microg.gms.ui.WorkProfileFragment" android:label="@string/service_name_work_profile" /> + + + + + + + + + + Device Attestation Play Store services Work profile + WearOS Google Play Games %1$s would like to use Play Games @@ -320,6 +321,25 @@ Please set up a password, PIN, or pattern lock screen." It is your responsibility to ensure that your usage of microG is in line with corporate policies. microG is provided on a best-effort basis and cannot guarantee to behave exactly as expected. + This is for debugging WearOS connections. + WearOS connections + Name + Enabled + Whether to use this connection configuration or not + Address + Package + Delete + Delete %1$s? + Drop database + Drops and recreates connectionConfigurations table + This will delete all connection configurations and recreate the table.\n\n + This action cannot be undone. + + Reset + Cancel + I agree + More + Allow Play Games account registration When playing games, you need to use a Play Games account to log in and record game-related functions, such as achievements, leaderboards, archives, etc. After disabling, Google accounts that are not bound to a Play Games account will not be automatically registered, and the game will not be able to log in and play. Allow uploading of game played diff --git a/play-services-core/src/main/res/xml/preferences_start.xml b/play-services-core/src/main/res/xml/preferences_start.xml index eb45098992..be71223279 100644 --- a/play-services-core/src/main/res/xml/preferences_start.xml +++ b/play-services-core/src/main/res/xml/preferences_start.xml @@ -67,6 +67,10 @@ android:icon="@drawable/ic_map_marker" android:key="pref_location" android:title="@string/service_name_location"/> + + + + + + + + + + + + + diff --git a/play-services-core/src/main/res/xml/preferences_wear_config.xml b/play-services-core/src/main/res/xml/preferences_wear_config.xml new file mode 100644 index 0000000000..991dc47c32 --- /dev/null +++ b/play-services-core/src/main/res/xml/preferences_wear_config.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/play-services-wearable/core/build.gradle b/play-services-wearable/core/build.gradle index 5675f7e736..6858188061 100644 --- a/play-services-wearable/core/build.gradle +++ b/play-services-wearable/core/build.gradle @@ -7,14 +7,13 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'maven-publish' apply plugin: 'signing' +apply plugin: 'com.squareup.wire' dependencies { implementation project(':play-services-base-core') implementation project(':play-services-location') implementation project(':play-services-wearable') - - implementation "org.microg:wearable:$wearableVersion" } android { @@ -51,6 +50,12 @@ android { } } +wire { + java { + + } +} + apply from: '../../gradle/publish-android.gradle' description = 'microG service implementation for play-services-wearable' diff --git a/play-services-wearable/core/src/main/AndroidManifest.xml b/play-services-wearable/core/src/main/AndroidManifest.xml index 9df69b73bd..4efb6fba6e 100644 --- a/play-services-wearable/core/src/main/AndroidManifest.xml +++ b/play-services-wearable/core/src/main/AndroidManifest.xml @@ -6,6 +6,20 @@ + + + + + + + + + + + + + + diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/A2AEncryption.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/A2AEncryption.java new file mode 100644 index 0000000000..51b6a16429 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/A2AEncryption.java @@ -0,0 +1,12 @@ +package org.microg.gms.wearable; + +// TODO: needed ukey2 or similar + +public class A2AEncryption { + private static final String TAG = "WearA2AEncryption"; + + public static final byte[] CONFIRM = {67, 79, 78, 70, 73, 82, 77}; + public static final byte[] CLIENT = {67, 76, 73, 69, 78, 84}; + public static final byte[] SERVER = {83, 69, 82, 86, 69, 82}; + private static final byte[] RESUME = {82, 69, 83, 85, 77, 69}; +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AccountMatching.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AccountMatching.java new file mode 100644 index 0000000000..0d04c35711 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AccountMatching.java @@ -0,0 +1,111 @@ +package org.microg.gms.wearable; + +import android.util.Log; + +import org.microg.gms.wearable.proto.AccountMatchingMessage; +import org.microg.gms.wearable.proto.ControlMessage; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; +import java.util.Collections; + +public class AccountMatching { + private static final String TAG = "AccountMatching"; + + private static final int CONTROL_ACCOUNT_MATCHING = 6; + + private static final int GET_ACCOUNTS = 1; + private static final int ACCOUNTS_RESPONSE = 2; + private static final int REMOVE_ACCOUNTS = 3; + private static final int REMOVE_ACCOUNTS_CONFIRM = 4; + private static final int CANCEL = 5; + + private final WearableImpl wearable; + + public AccountMatching(WearableImpl wearable) { + this.wearable = wearable; + } + + public boolean handleControlMessage(WearableConnection connection, String sourceNodeId, ControlMessage control) { + if (control == null) { + return false; + } + + if (control.type == null || control.type != CONTROL_ACCOUNT_MATCHING) { + return false; + } + + AccountMatchingMessage msg = control.accountMatching; + + if (msg == null) { + Log.w(TAG, "ACCOUNT_MATCHING control message with null body"); + return true; + } + + int type = msg.type != null ? msg.type : 0; + + Log.d(TAG, "[" + sourceNodeId + "] account matching type=" + type); + + switch (type) { + case GET_ACCOUNTS: + sendAccountsResponse(connection, sourceNodeId); + break; + + case REMOVE_ACCOUNTS: + sendRemoveAccountsConfirm(connection, sourceNodeId); + break; + + case CANCEL: + break; + + case ACCOUNTS_RESPONSE: + break; + + default: + break; + } + + return true; + } + + private void sendAccountsResponse(WearableConnection connection, String targetNodeId) { + try { + AccountMatchingMessage reply = new AccountMatchingMessage.Builder() + .type(ACCOUNTS_RESPONSE) + .entries(Collections.emptyList()) + .build(); + + connection.writeMessage(buildControlMessage(targetNodeId, reply)); + Log.d(TAG, "Sent ACCOUNTS_RESPONSE (empty) for " + targetNodeId); + + } catch (IOException e) { + Log.e(TAG, "Failed to send ACCOUNTS_RESPONSE for " + targetNodeId, e); + } + } + + private void sendRemoveAccountsConfirm(WearableConnection connection, String targetNodeId) { + try { + AccountMatchingMessage reply = new AccountMatchingMessage.Builder() + .type(REMOVE_ACCOUNTS_CONFIRM) + .build(); + + connection.writeMessage(buildControlMessage(targetNodeId, reply)); + Log.d(TAG, "Sent REMOVE_ACCOUNTS_CONFIRM for "+ targetNodeId); + + } catch (IOException e) { + Log.e(TAG, "Failed to send REMOVE_ACCOUNTS_CONFIRM for " + targetNodeId, e); + } + } + + private RootMessage buildControlMessage(String targetNodeId, + AccountMatchingMessage msg) { + ControlMessage ctrl = new ControlMessage.Builder() + .type(CONTROL_ACCOUNT_MATCHING) + .accountMatching(msg) + .build(); + + return new RootMessage.Builder() + .controlMessage(ctrl) + .build(); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AssetFetcher.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AssetFetcher.java new file mode 100644 index 0000000000..70b40f6926 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AssetFetcher.java @@ -0,0 +1,336 @@ +package org.microg.gms.wearable; + +import android.database.Cursor; +import android.os.Handler; +import android.util.Log; + +import com.google.android.gms.wearable.Asset; + +import org.microg.gms.wearable.channel.ChannelManager; +import org.microg.gms.wearable.proto.FetchAsset; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class AssetFetcher { + private static final String TAG = "GmsWearAssetFetch"; + + private final NodeDatabaseHelper nodeDatabase; + private final Handler networkHandler; + + private final Set fetchingAssets = Collections.newSetFromMap(new ConcurrentHashMap()); + + private final Map failedAssets = new ConcurrentHashMap<>(); + + private static final int ASSET_BATCH_SIZE = 4; + private static final int MAX_RETRY_COUNT = 3; + private static final long RETRY_COOLDOWN_MS = 5000; // 5 seconds before retry + private static final long FAILED_ASSET_EXPIRY_MS = 300000; // 5 minutes + private static final int MAX_INFLIGHT_FETCHES = 8; + private static final long INFLIGHT_RETRY_DELAY_MS = 750; + + public AssetFetcher(NodeDatabaseHelper nodeDatabase, Handler networkHandler) { + this.nodeDatabase = nodeDatabase; + this.networkHandler = networkHandler; + } + + public void fetchMissingAssets(String nodeId, WearableConnection connection, + Map activeConnections, + ChannelManager channelManager) { + if (connection == null) { + Log.d(TAG, "Connection no longer active for node: " + nodeId); + return; + } + + cleanupExpiredFailures(); + + Cursor cursor = nodeDatabase.listMissingAssets(); + if (cursor == null) { + return; + } + + final int colPackageName = cursor.getColumnIndexOrThrow("packageName"); + final int colSignatureDigest = cursor.getColumnIndexOrThrow("signatureDigest"); + final int colAssetDigest = cursor.getColumnIndexOrThrow("assets_digest"); + + try { + int fetchCount = 0; + int skippedCount = 0; + int alreadyFetchingCount = 0; + + while (cursor.moveToNext()) { + if (!activeConnections.containsKey(nodeId)) { + Log.d(TAG, "Connection closed during asset fetch, stopping (fetched=" + + fetchCount + ", skipped=" + skippedCount + ")"); + break; + } + + if (fetchingAssets.size() >= MAX_INFLIGHT_FETCHES) { + Log.d(TAG, "fetchMissingAssets: In-flight FetchAsset cap reached (" + + fetchingAssets.size() + "); deferring remaining for node " + nodeId); + + networkHandler.postDelayed(() -> fetchMissingAssets( + nodeId, activeConnections.get(nodeId),activeConnections, channelManager), + INFLIGHT_RETRY_DELAY_MS); + break; + } + + String assetDigest = cursor.getString(colAssetDigest); + String packageName = cursor.getString(colPackageName); + String signatureDigest = cursor.getString(colSignatureDigest); + + if (fetchingAssets.contains(assetDigest)) { + alreadyFetchingCount++; + continue; + } + + AssetFetchAttempt attempt = failedAssets.get(assetDigest); + if (attempt != null) { + if (attempt.retryCount >= MAX_RETRY_COUNT) { + skippedCount++; + continue; + } + + long timeSinceLastAttempt = System.currentTimeMillis() - attempt.lastAttemptTime; + if (timeSinceLastAttempt < RETRY_COOLDOWN_MS) { + skippedCount++; + continue; + } + } + + try { + fetchingAssets.add(assetDigest); + + connection.writeMessage(new RootMessage.Builder() + .fetchAsset(new FetchAsset.Builder() + .assetName(assetDigest) + .packageName(packageName) + .signatureDigest(signatureDigest) + .build()) + .build()); + + fetchCount++; + + failedAssets.remove(assetDigest); + + if (fetchCount % ASSET_BATCH_SIZE == 0) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Log.d(TAG, "Asset fetch interrupted"); + break; + } + } + + } catch (IOException e) { + Log.w(TAG, "Error fetching asset " + assetDigest + + " (fetched " + fetchCount + " so far): " + e.getMessage()); + + recordFailure(assetDigest); + + fetchingAssets.remove(assetDigest); + + if (isConnectionError(e)) { + break; + } + } + } + + if (fetchCount > 0 || skippedCount > 0 || alreadyFetchingCount > 0) { + Log.d(TAG, "Asset fetch summary: fetched=" + fetchCount + + ", skipped=" + skippedCount + + ", alreadyFetching=" + alreadyFetchingCount); + } + + if (fetchCount > 100 && channelManager != null) { + Log.d(TAG, "Large asset batch (" + fetchCount + "), applying cooldown"); + channelManager.setOperationCooldown(1000); + } + + } finally { + cursor.close(); + } + } + + public void fetchMissingAssetsForRecord(WearableConnection connection, + DataItemRecord record, + List missingAssets) { + int successCount = 0; + int skipCount = 0; + + for (Asset asset : missingAssets) { + String digest = asset.getDigest(); + + if (fetchingAssets.contains(digest)) { + skipCount++; + continue; + } + + AssetFetchAttempt attempt = failedAssets.get(digest); + if (attempt != null) { + if (attempt.retryCount >= MAX_RETRY_COUNT) { + Log.d(TAG, "Asset " + digest + " failed too many times, skipping"); + skipCount++; + continue; + } + + long timeSinceLastAttempt = System.currentTimeMillis() - attempt.lastAttemptTime; + if (timeSinceLastAttempt < RETRY_COOLDOWN_MS) { + skipCount++; + continue; + } + } + + try { + Log.d(TAG, "Fetching missing asset for record: " + digest); + + fetchingAssets.add(digest); + + FetchAsset fetchAsset = new FetchAsset.Builder() + .assetName(digest) + .packageName(record.packageName) + .signatureDigest(record.signatureDigest) + .permission(false) + .build(); + + connection.writeMessage(new RootMessage.Builder() + .fetchAsset(fetchAsset) + .build()); + + successCount++; + + failedAssets.remove(digest); + + } catch (IOException e) { + Log.w(TAG, "Error fetching asset " + digest + " for record", e); + + recordFailure(digest); + fetchingAssets.remove(digest); + } + } + + if (successCount > 0 || skipCount > 0) { + Log.d(TAG, "Record asset fetch: success=" + successCount + ", skipped=" + skipCount); + } + } + + public void onAssetReceived(String digest) { + fetchingAssets.remove(digest); + failedAssets.remove(digest); + Log.v(TAG, "Asset received and tracked: " + digest); + } + + public void onAssetFetchFailed(String digest) { + fetchingAssets.remove(digest); + recordFailure(digest); + Log.d(TAG, "Asset fetch failed: " + digest); + } + + private void recordFailure(String digest) { + AssetFetchAttempt attempt = failedAssets.get(digest); + if (attempt == null) { + attempt = new AssetFetchAttempt(digest); + failedAssets.put(digest, attempt); + } + attempt.recordFailure(); + } + + private boolean isConnectionError(IOException e) { + String message = e.getMessage(); + if (message == null) return false; + + return message.contains("Connection") || + message.contains("Broken pipe") || + message.contains("Socket closed") || + message.contains("Connection reset"); + } + + private void cleanupExpiredFailures() { + long now = System.currentTimeMillis(); + List toRemove = new ArrayList<>(); + + for (Map.Entry entry : failedAssets.entrySet()) { + if (now - entry.getValue().firstAttemptTime > FAILED_ASSET_EXPIRY_MS) { + toRemove.add(entry.getKey()); + } + } + + for (String digest : toRemove) { + failedAssets.remove(digest); + } + + if (!toRemove.isEmpty()) { + Log.d(TAG, "Cleaned up " + toRemove.size() + " expired failed asset records"); + } + } + + public AssetFetchStats getStats() { + int failedCount = 0; + int retryingCount = 0; + + for (AssetFetchAttempt attempt : failedAssets.values()) { + if (attempt.retryCount >= MAX_RETRY_COUNT) { + failedCount++; + } else { + retryingCount++; + } + } + + return new AssetFetchStats( + fetchingAssets.size(), + retryingCount, + failedCount + ); + } + + public void resetTracking() { + fetchingAssets.clear(); + failedAssets.clear(); + Log.d(TAG, "Asset fetch tracking reset"); + } + + private static class AssetFetchAttempt { + final String digest; + final long firstAttemptTime; + long lastAttemptTime; + int retryCount; + + AssetFetchAttempt(String digest) { + this.digest = digest; + this.firstAttemptTime = System.currentTimeMillis(); + this.lastAttemptTime = firstAttemptTime; + this.retryCount = 0; + } + + void recordFailure() { + this.lastAttemptTime = System.currentTimeMillis(); + this.retryCount++; + } + } + + public static class AssetFetchStats { + public final int currentlyFetching; + public final int retrying; + public final int failed; + + AssetFetchStats(int currentlyFetching, int retrying, int failed) { + this.currentlyFetching = currentlyFetching; + this.retrying = retrying; + this.failed = failed; + } + + @Override + public String toString() { + return "AssetFetchStats" + + "{fetching="+currentlyFetching+", " + + "retrying="+retrying+", " + + "failed="+failed+"}"; + } + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AssetManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AssetManager.java new file mode 100644 index 0000000000..70faf628d3 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AssetManager.java @@ -0,0 +1,282 @@ +package org.microg.gms.wearable; + +import android.util.Log; + +import com.google.android.gms.wearable.Wearable; + +import org.microg.gms.wearable.proto.AppKey; +import org.microg.gms.wearable.proto.AppKeys; +import org.microg.gms.wearable.proto.FetchAsset; +import org.microg.gms.wearable.proto.FilePiece; +import org.microg.gms.wearable.proto.RootMessage; +import org.microg.gms.wearable.proto.SetAsset; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import okio.ByteString; + +public class AssetManager { + private static final String TAG = "WearAssetManager"; + + private static final int CHUNK_SIZE = 12215; + + private final WearableImpl wearable; + + private final ExecutorService ioExecutor = Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, TAG); + t.setDaemon(true); + return t; + } + ); + + private final Map entries = new ConcurrentHashMap<>(); + private final Map writers = new ConcurrentHashMap<>(); + private final List listeners = new CopyOnWriteArrayList<>(); + + public AssetManager(WearableImpl wearable) { + this.wearable = wearable; + } + + public void addWriter(String nodeId, WearableWriter writer) { + writers.put(nodeId, writer); + + if (entries.isEmpty()) return; + + Log.d(TAG, "addWriter: replaying " + entries.size() + " to " + nodeId); + + for (FetchEntry e : entries.values()) { + writer.enqueue(buildFetchMessage(e)); + } + } + + public void removeWriter(String nodeId) { + writers.remove(nodeId); + Log.d(TAG, "removeWriter: " + nodeId); + } + + public void onAssetMissing(String digest, String packageName, String signature) { + FetchEntry existing = entries.get(digest); + if (existing != null && !existing.permission) { + Log.d(TAG, "onAssetMissing: full fetch already pending for " + digest); + return; + } + enqueueFetch(digest, packageName, signature, false); + } + + public void onAssetPermissionMissing(String digest, String packageName, String signature) { + Log.d(TAG, "onAssetPermissionMissing: " + digest); + FetchEntry existing = entries.get(digest); + if (existing != null && !existing.permission) { + Log.d(TAG, "onAssetPermissionMissing: full fetch already pending for " + digest); + return; + } + enqueueFetch(digest, packageName, signature, true); + } + + private void enqueueFetch(String digest, String packageName, + String signature, boolean permission) { + FetchEntry fetchEntry = new FetchEntry(digest, packageName, signature, permission); + entries.put(digest, fetchEntry); + + RootMessage msg = buildFetchMessage(fetchEntry); + for (WearableWriter writer: writers.values()) { + writer.enqueue(msg); + } + } + + private static RootMessage buildFetchMessage(FetchEntry entry) { + return new RootMessage.Builder() + .fetchAsset( + new FetchAsset.Builder() + .assetName(entry.digest) + .packageName(entry.packageName) + .signatureDigest(entry.signatureDigest) + .permission(entry.permission) + .build() + ).build(); + } + + public void onAckAsset(String digest) { + if (digest == null) return; + Log.d(TAG, "onAckAsset: " + digest); + entries.remove(digest); + checkCompletion(); + } + + public void handleFetchAsset(WearableConnection connection, String sourceNodeId, FetchAsset fetchAsset) { + if (fetchAsset == null || fetchAsset.assetName == null) return; + boolean permission = Boolean.TRUE.equals(fetchAsset.permission); + Log.d(TAG, "handleFetchAsset: digest=" + fetchAsset.assetName + + " permission=" + permission + " from=" + sourceNodeId); + + ioExecutor.execute(() -> { + String digest = fetchAsset.assetName; + File assetFile = wearable.createAssetFile(digest); + + if (!assetFile.exists()){ + Log.d(TAG, "handleFetchAsset: asset not found locally, ignoring: " + digest); + return; + } + + AppKeys appKeys = resolveAppKeys(digest, fetchAsset.packageName, fetchAsset.signatureDigest); + + if (permission) { + sendPermissionConfirmation(connection, digest, appKeys); + } else { + sendFullAsset(connection, digest, assetFile, appKeys); + } + }); + } + + private void sendPermissionConfirmation(WearableConnection connection, + String digest, AppKeys appKeys) { + try { + connection.writeMessage( + new RootMessage.Builder() + .setAsset( + new SetAsset.Builder() + .digest(digest) + .appkeys(appKeys) + .build() + ) + .hasAsset(false) + .build() + ); + Log.d(TAG, "sendPermissionConfirmation: sent for " + digest); + } catch (IOException e) { + Log.w(TAG, "sendPermissionConfirmation: failed for " + digest + ": " + e.getMessage()); + } + } + + private void sendFullAsset(WearableConnection connection, String digest, + File assetFile, AppKeys appKeys) { + try { + RootMessage msg = new RootMessage.Builder() + .setAsset( + new SetAsset.Builder() + .digest(digest) + .appkeys(appKeys) + .build() + ) + .hasAsset(true) + .build(); + connection.writeMessage(msg); + + String fileName = WearableConnection.calculateDigest(msg.encode()); + streamFilePeces(connection, assetFile, fileName, digest); + + Log.d(TAG, "sendFullAsset: competed for " + digest); + } catch (IOException e) { + Log.w(TAG, "sendFullAsset: failed for " + digest + ": " + e.getMessage()); + } + } + + private void streamFilePeces(WearableConnection connection, File file, + String fileName, String digest) throws IOException { + try (FileInputStream fis = new FileInputStream(file)) { + byte[] buf = new byte[CHUNK_SIZE]; + ByteString pending = null; + int n; + + while ((n = fis.read(buf)) > 0) { + if (pending != null) { + connection.writeMessage( + new RootMessage.Builder() + .filePiece( + new FilePiece(fileName, false, pending, null) + ) + .build() + ); + } + pending = ByteString.of(buf, 0, n); + } + + connection.writeMessage( + new RootMessage.Builder() + .filePiece(new FilePiece(fileName, true, pending, digest)) + .build() + ); + } + } + + public void addCompletionListener(Runnable listener) { + listeners.add(listener); + checkCompletion(); + } + + private void checkCompletion() { + if (!entries.isEmpty()) return; + + List toFire = new ArrayList<>(listeners); + listeners.removeAll(toFire); + + for (Runnable r : toFire) { + try { + r.run(); + } catch (Exception e) { + Log.w(TAG, "checkCompletion: " + e.getMessage()); + } + } + } + + public void onAssetReceived(String digest) { + entries.remove(digest); + checkCompletion(); + } + + public void onAssetFetchFailed(String digest) { + entries.remove(digest); + checkCompletion(); + } + + public int getFetchCount() { + return entries.size(); + } + + public void reset() { + entries.clear(); + writers.clear(); + listeners.clear(); + } + + public void shutdown() { + reset(); + ioExecutor.shutdownNow(); + } + + private AppKeys resolveAppKeys(String digest, String fallbackPackageName, String fallbackSignature) { + if (fallbackPackageName == null || fallbackPackageName.isEmpty()) { + return new AppKeys(Collections.emptyList()); + } + + List keys = new ArrayList<>(1); + keys.add(new AppKey(fallbackPackageName, fallbackSignature != null ? fallbackSignature : "")); + return new AppKeys(keys); + } + + private static final class FetchEntry { + final String digest; + final String packageName; + final String signatureDigest; + final boolean permission; + + FetchEntry(String digest, String packageName, String signatureDigest, boolean permission) { + this.digest = digest; + this.packageName = packageName; + this.signatureDigest = signatureDigest; + this.permission = permission; + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CapabilityManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CapabilityManager.java index 0c8f59ffc6..50ce3e0f3f 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CapabilityManager.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CapabilityManager.java @@ -18,6 +18,7 @@ import android.content.Context; import android.net.Uri; +import android.util.Log; import com.google.android.gms.common.api.CommonStatusCodes; import com.google.android.gms.common.data.DataHolder; @@ -27,16 +28,21 @@ import org.microg.gms.common.PackageUtils; +import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.Set; public class CapabilityManager { + private static final String TAG = "CapabilityManager"; + private static final Uri ROOT = Uri.parse("wear:/capabilities/"); private final Context context; private final WearableImpl wearable; private final String packageName; - private Set capabilities = new HashSet(); + private final Object lock = new Object(); + + private final Set capabilities = new HashSet(); public CapabilityManager(Context context, WearableImpl wearable, String packageName) { this.context = context; @@ -44,6 +50,35 @@ public CapabilityManager(Context context, WearableImpl wearable, String packageN this.packageName = packageName; } + public enum CapabilityType { + STATIC("s", "+", "+#"), + DYNAMIC("d", "-", "-#"); + + public final String typeCode; + public final String addSymbol; + public final String addSymbolWithHash; + + CapabilityType(String typeCode, String addSymbol, String addSymbolWithHash) { + this.typeCode = typeCode; + this.addSymbol = addSymbol; + this.addSymbolWithHash = addSymbolWithHash; + } + + public static CapabilityType fromBytes(byte[] data) { + if (data == null || data.length == 0) return DYNAMIC; + + String code = new String(data, 0, 1, StandardCharsets.UTF_8); + + if (STATIC.typeCode.equals(code)) return STATIC; + + return DYNAMIC; + } + + public byte[] toBytes() { + return typeCode.getBytes(StandardCharsets.UTF_8); + } + } + private Uri buildCapabilityUri(String capability, boolean withAuthority) { Uri.Builder builder = ROOT.buildUpon(); if (withAuthority) builder.authority(wearable.getLocalNodeId()); @@ -54,32 +89,118 @@ private Uri buildCapabilityUri(String capability, boolean withAuthority) { } public Set getNodesForCapability(String capability) { - DataHolder dataHolder = wearable.getDataItemsByUriAsHolder(buildCapabilityUri(capability, false), packageName); + DataHolder localHolder = wearable.getDataItemsByUriAsHolder(buildCapabilityUri(capability, false), packageName); Set nodes = new HashSet<>(); - for (int i = 0; i < dataHolder.getCount(); i++) { - nodes.add(dataHolder.getString("host", i, 0)); + try{ + for (int i = 0; i < localHolder.getCount(); i++) { + nodes.add(localHolder.getString("host", i, 0)); + } + } finally { + localHolder.close(); } - dataHolder.close(); + + Set remoteNodes = wearable.getNodesByCapabilityPath("/capabilities/", capability); + nodes.addAll(remoteNodes); + return nodes; } public int add(String capability) { - if (this.capabilities.contains(capability)) { - return WearableStatusCodes.DUPLICATE_CAPABILITY; + return addWithType(capability, CapabilityType.DYNAMIC); +// if (this.capabilities.contains(capability)) { +// return WearableStatusCodes.DUPLICATE_CAPABILITY; +// } +// DataItemInternal dataItem = new DataItemInternal(buildCapabilityUri(capability, true)); +// DataItemRecord record = wearable.putDataItem(packageName, PackageUtils.firstSignatureDigest(context, packageName), wearable.getLocalNodeId(), dataItem); +// this.capabilities.add(capability); +// wearable.syncRecordToAll(record); +// return CommonStatusCodes.SUCCESS; + } + + public int addWithType(String capability, CapabilityType type) { + synchronized (lock) { + Uri uri = buildCapabilityUri(capability, true); + DataHolder existingData = wearable.getDataItemsByUriAsHolder(uri, packageName); + + try { + if (existingData.getCount() > 0) { + byte[] data = existingData.getByteArray("data", 0, 0); + CapabilityType existingType = CapabilityType.fromBytes(data); + + if (existingType == type) + return CommonStatusCodes.SUCCESS; + + if (existingType == CapabilityType.STATIC) + return WearableStatusCodes.DUPLICATE_CAPABILITY; + } + } finally { + existingData.close(); + } + + DataItemInternal dataItem = new DataItemInternal(uri); + dataItem.data = type.toBytes(); + + DataItemRecord record = wearable.putDataItem( + packageName, + PackageUtils.firstSignatureDigest(context, packageName), + wearable.getLocalNodeId(), + dataItem + ); + + if (record != null) { + capabilities.add(capability); + wearable.syncRecordToAll(record); + Log.d(TAG, "Added capability: " + capability + " (type=" + type + ")"); + return CommonStatusCodes.SUCCESS; + } else { + Log.e(TAG, "Failed to add capability: " + capability); + return CommonStatusCodes.ERROR; + } } - DataItemInternal dataItem = new DataItemInternal(buildCapabilityUri(capability, true)); - DataItemRecord record = wearable.putDataItem(packageName, PackageUtils.firstSignatureDigest(context, packageName), wearable.getLocalNodeId(), dataItem); - this.capabilities.add(capability); - wearable.syncRecordToAll(record); - return CommonStatusCodes.SUCCESS; } public int remove(String capability) { - if (!this.capabilities.contains(capability)) { - return WearableStatusCodes.UNKNOWN_CAPABILITY; + synchronized (lock) { + if (!capabilities.contains(capability)) { + Uri uri = buildCapabilityUri(capability, true); + DataHolder existingData = wearable.getDataItemsByUriAsHolder(uri, packageName); + try { + if (existingData.getCount() == 0) { + Log.w(TAG, "Capability not found: " + capability); + return WearableStatusCodes.UNKNOWN_CAPABILITY; + } + } finally { + existingData.close(); + } + } + +// if (!this.capabilities.contains(capability)) { +// return WearableStatusCodes.UNKNOWN_CAPABILITY; +// } + wearable.deleteDataItems(buildCapabilityUri(capability, true), packageName); + capabilities.remove(capability); + Log.d(TAG, "Removed capability: " + capability); + return CommonStatusCodes.SUCCESS; + } + } + + public Set getLocalCapabilities() { + synchronized (lock) { + return new HashSet<>(capabilities); + } + } + + public boolean hasCapability(String capability) { + synchronized (lock) { + return capabilities.contains(capability); + } + } + + public void clearAll() { + synchronized (lock) { + for (String capability: new HashSet<>(capabilities)) { + remove(capability); + } } - wearable.deleteDataItems(buildCapabilityUri(capability, true), packageName); - capabilities.remove(capability); - return CommonStatusCodes.SUCCESS; } } diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ClockworkNodePreferences.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ClockworkNodePreferences.java index d6fc016af0..2637dc1763 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ClockworkNodePreferences.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ClockworkNodePreferences.java @@ -19,6 +19,8 @@ import android.content.Context; import android.content.SharedPreferences; +import java.security.SecureRandom; +import java.util.Random; import java.util.UUID; public class ClockworkNodePreferences { @@ -26,19 +28,25 @@ public class ClockworkNodePreferences { private static final String CLOCKWORK_NODE_PREFERENCES = "cw_node"; private static final String CLOCKWORK_NODE_PREFERENCE_NODE_ID = "node_id"; private static final String CLOCKWORK_NODE_PREFERENCE_NEXT_SEQ_ID_BLOCK = "nextSeqIdBlock"; + private static final String CLOCKWORK_NODE_PREFERENCE_PEER_NODE_ID = "peer_node_id"; + private static final String CLOCKWORK_NODE_PREFERENCE_NETWORK_ID = "network_id"; + private static final String CLOCKWORK_NODE_PREFERENCE_PEER_ANDROID_ID = "peer_android_id"; private static final Object lock = new Object(); private static long seqIdBlock; private static long seqIdInBlock = -1; - private Context context; + private final Context context; + private final Random random; public ClockworkNodePreferences(Context context) { this.context = context; + this.random = new SecureRandom(); } public String getLocalNodeId() { - SharedPreferences preferences = context.getSharedPreferences(CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE); + SharedPreferences preferences = context.getSharedPreferences( + CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE); String nodeId = preferences.getString(CLOCKWORK_NODE_PREFERENCE_NODE_ID, null); if (nodeId == null) { nodeId = UUID.randomUUID().toString(); @@ -49,14 +57,66 @@ public String getLocalNodeId() { public long getNextSeqId() { synchronized (lock) { - SharedPreferences preferences = context.getSharedPreferences(CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE); - if (seqIdInBlock < 0) seqIdInBlock = 1000; + SharedPreferences preferences = context.getSharedPreferences( + CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE); + if (seqIdInBlock < 0) { + seqIdBlock = preferences.getLong( + CLOCKWORK_NODE_PREFERENCE_NEXT_SEQ_ID_BLOCK, 100); + preferences.edit() + .putLong(CLOCKWORK_NODE_PREFERENCE_NEXT_SEQ_ID_BLOCK, seqIdBlock + 1000) + .commit(); + seqIdInBlock = 0; + } + if (seqIdInBlock >= 1000) { - seqIdBlock = preferences.getLong(CLOCKWORK_NODE_PREFERENCE_NEXT_SEQ_ID_BLOCK, 100); - preferences.edit().putLong(CLOCKWORK_NODE_PREFERENCE_NEXT_SEQ_ID_BLOCK, seqIdBlock + seqIdInBlock).apply(); + seqIdBlock = preferences.getLong( + CLOCKWORK_NODE_PREFERENCE_NEXT_SEQ_ID_BLOCK, seqIdBlock + 1000); + preferences.edit() + .putLong(CLOCKWORK_NODE_PREFERENCE_NEXT_SEQ_ID_BLOCK,seqIdBlock + 1000) + .commit(); seqIdInBlock = 0; } + return seqIdBlock + seqIdInBlock++; } } + + public String getPeerNodeId() { + return context.getSharedPreferences(CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE).getString(CLOCKWORK_NODE_PREFERENCE_PEER_NODE_ID, null); + } + + public void setPeerNodeId(String peerNodeId) { + context.getSharedPreferences(CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE).edit().putString(CLOCKWORK_NODE_PREFERENCE_PEER_NODE_ID, peerNodeId).apply(); + } + + public String getNetworkId() { + return context.getSharedPreferences(CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE) + .getString(CLOCKWORK_NODE_PREFERENCE_NETWORK_ID, null); + } + + public void setNetworkId(String networkId) { + context.getSharedPreferences(CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE).edit() + .putString(CLOCKWORK_NODE_PREFERENCE_NETWORK_ID, networkId).apply(); + } + + public long getPeerAndroidId() { + return context.getSharedPreferences(CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE) + .getLong(CLOCKWORK_NODE_PREFERENCE_PEER_ANDROID_ID, 0L); + } + + public void setPeerAndroidId(long peerAndroidId) { + context.getSharedPreferences(CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE).edit() + .putLong(CLOCKWORK_NODE_PREFERENCE_PEER_ANDROID_ID, peerAndroidId).apply(); + } + + public void clear() { + synchronized (lock) { + SharedPreferences preferences = context.getSharedPreferences( + CLOCKWORK_NODE_PREFERENCES, Context.MODE_PRIVATE); + preferences.edit().clear().commit(); + + seqIdBlock = 0; + seqIdInBlock = -1; + } + } } diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConfigurationDatabaseHelper.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConfigurationDatabaseHelper.java index 4aa4b58b97..bdc96b77e2 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConfigurationDatabaseHelper.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConfigurationDatabaseHelper.java @@ -19,8 +19,10 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; +import android.util.Log; import com.google.android.gms.wearable.ConnectionConfiguration; @@ -29,35 +31,189 @@ import java.util.Random; public class ConfigurationDatabaseHelper extends SQLiteOpenHelper { + private static final String TAG = "ConfigDB"; public static final String NULL_STRING = "NULL_STRING"; public static final String TABLE_NAME = "connectionConfigurations"; public static final String BY_NAME = "name=?"; + private static final String COLUMN_ID = "_id"; + private static final String COLUMN_ANDROID_ID = "androidId"; + private static final String COLUMN_ALLOWED_CONFIG_PACKAGES = "allowedConfigPackages"; + private static final String COLUMN_NAME = "name"; + private static final String COLUMN_PAIRED_BT_ADDRESS = "pairedBtAddress"; + private static final String COLUMN_CONNECTION_TYPE = "connectionType"; + private static final String COLUMN_ROLE = "role"; + private static final String COLUMN_CONNECTION_ENABLED = "connectionEnabled"; + private static final String COLUMN_NODE_ID = "nodeId"; + private static final String COLUMN_CRYPTO = "crypto"; + private static final String COLUMN_PACKAGE_NAME = "packageName"; + private static final String COLUMN_IS_MIGRATING = "isMigrating"; + private static final String COLUMN_DATA_ITEM_SYNC_ENABLED = "dataItemSyncEnabled"; + private static final String COLUMN_RESTRICTIONS = "restrictions"; + private static final String COLUMN_REMOVE_CONNECTION_WHEN_BOND_REMOVED = "removeConnectionWhenBondRemovedByUser"; + private static final String COLUMN_CONNECTION_DELAY_FILTERS = "connectionDelayFilters"; + private static final String COLUMN_MAX_SUPPORTED_REMOTE_ANDROID_SDK = "maxSupportedRemoteAndroidSdkVersion"; + private static final String COLUMN_PEER_NODE_ID = "peerNodeId"; + public ConfigurationDatabaseHelper(Context context) { - super(context, "connectionconfig.db", null, 2); + super(context, "connectionconfig.db", null, 12); + } + + public void dropConfig(SQLiteDatabase db) { + try { + db.execSQL("DROP TABLE IF EXISTS connectionConfigurations"); + } catch (SQLException e) { + Log.e(TAG, "Error when dropping database", e); + throw e; + } } @Override public void onCreate(SQLiteDatabase db) { - db.execSQL("CREATE TABLE connectionConfigurations (_id INTEGER PRIMARY KEY AUTOINCREMENT,androidId TEXT,name TEXT NOT NULL,pairedBtAddress TEXT NOT NULL,connectionType INTEGER NOT NULL,role INTEGER NOT NULL,connectionEnabled INTEGER NOT NULL,nodeId TEXT, UNIQUE(name) ON CONFLICT REPLACE);"); + try { + db.execSQL("CREATE TABLE " + TABLE_NAME + " (" + + COLUMN_ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + COLUMN_ANDROID_ID + " TEXT," + + COLUMN_ALLOWED_CONFIG_PACKAGES + " TEXT," + + COLUMN_NAME + " TEXT NOT NULL," + + COLUMN_PAIRED_BT_ADDRESS + " TEXT NOT NULL," + + COLUMN_CONNECTION_TYPE + " INTEGER NOT NULL," + + COLUMN_ROLE + " INTEGER NOT NULL," + + COLUMN_CONNECTION_ENABLED + " INTEGER NOT NULL," + + COLUMN_NODE_ID + " TEXT," + + COLUMN_CRYPTO + " TEXT," + + COLUMN_PACKAGE_NAME + " TEXT," + + COLUMN_IS_MIGRATING + " INTEGER DEFAULT 0," + + COLUMN_DATA_ITEM_SYNC_ENABLED + " INTEGER DEFAULT 1," + + COLUMN_RESTRICTIONS + " BLOB," + + COLUMN_REMOVE_CONNECTION_WHEN_BOND_REMOVED + " INTEGER DEFAULT 1," + + COLUMN_CONNECTION_DELAY_FILTERS + " BLOB," + + COLUMN_MAX_SUPPORTED_REMOTE_ANDROID_SDK + " INTEGER DEFAULT 0," + + COLUMN_PEER_NODE_ID + " TEXT," + + " UNIQUE(" + COLUMN_NAME + ") ON CONFLICT REPLACE);"); + } catch (SQLException e) { + Log.e(TAG, "Error creating database", e); + throw e; + } + } + + public void resetConnectionConfigurations(SQLiteDatabase db) { + dropConfig(db); + onCreate(db); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + try { + Log.i(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion); + + if (oldVersion < 2) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s TEXT;", + TABLE_NAME, COLUMN_NODE_ID)); + oldVersion = 2; + } + + if (oldVersion < 3) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s TEXT;", + TABLE_NAME, COLUMN_CRYPTO)); + oldVersion = 3; + } + if (oldVersion < 4) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s TEXT;", + TABLE_NAME, COLUMN_PACKAGE_NAME)); + oldVersion = 4; + } + + if (oldVersion < 5) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s TEXT;", + TABLE_NAME, COLUMN_ALLOWED_CONFIG_PACKAGES)); + oldVersion = 5; + } + + if (oldVersion < 6) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER DEFAULT 0;", + TABLE_NAME, COLUMN_IS_MIGRATING)); + oldVersion = 6; + } + + if (oldVersion < 7) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER DEFAULT 1;", + TABLE_NAME, COLUMN_DATA_ITEM_SYNC_ENABLED)); + oldVersion = 7; + } + + if (oldVersion < 8) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s BLOB;", + TABLE_NAME, COLUMN_RESTRICTIONS)); + oldVersion = 8; + } + + if (oldVersion < 9) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER DEFAULT 1;", + TABLE_NAME, COLUMN_REMOVE_CONNECTION_WHEN_BOND_REMOVED)); + oldVersion = 9; + } + + if (oldVersion < 10) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s BLOB;", + TABLE_NAME, COLUMN_CONNECTION_DELAY_FILTERS)); + oldVersion = 10; + } + + if (oldVersion < 11) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER DEFAULT 0;", + TABLE_NAME, COLUMN_MAX_SUPPORTED_REMOTE_ANDROID_SDK)); + } + + + if (oldVersion < 12) { + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s TEXT;", + TABLE_NAME, COLUMN_PEER_NODE_ID)); + } + + } catch (SQLException e) { + Log.e(TAG, "Error upgrading database", e); + throw e; + } } private static ConnectionConfiguration configFromCursor(final Cursor cursor) { - String name = cursor.getString(cursor.getColumnIndexOrThrow("name")); - String pairedBtAddress = cursor.getString(cursor.getColumnIndexOrThrow("pairedBtAddress")); - int connectionType = cursor.getInt(cursor.getColumnIndexOrThrow("connectionType")); - int role = cursor.getInt(cursor.getColumnIndexOrThrow("role")); - int enabled = cursor.getInt(cursor.getColumnIndexOrThrow("connectionEnabled")); - String nodeId = cursor.getString(cursor.getColumnIndexOrThrow("nodeId")); + String name = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)); + String pairedBtAddress = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_PAIRED_BT_ADDRESS)); + int connectionType = cursor.getInt(cursor.getColumnIndexOrThrow(COLUMN_CONNECTION_TYPE)); + int role = cursor.getInt(cursor.getColumnIndexOrThrow(COLUMN_ROLE)); + int enabled = cursor.getInt(cursor.getColumnIndexOrThrow(COLUMN_CONNECTION_ENABLED)); + String nodeId = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NODE_ID)); + String packageName = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_PACKAGE_NAME)); + int peerNodeIdCol = cursor.getColumnIndex(COLUMN_PEER_NODE_ID); + String peerNodeId = peerNodeIdCol >= 0 ? cursor.getString(peerNodeIdCol) : null; + + int dataItemSyncCol = cursor.getColumnIndex(COLUMN_DATA_ITEM_SYNC_ENABLED); + boolean dataItemSyncEnabled = dataItemSyncCol < 0 || cursor.getInt(dataItemSyncCol) != 0; + + int removeBondCol = cursor.getColumnIndex(COLUMN_REMOVE_CONNECTION_WHEN_BOND_REMOVED); + boolean removeConnectionWhenBondRemovedByUser = removeBondCol < 0 || cursor.getInt(removeBondCol) != 0; + + int maxSdkCol = cursor.getColumnIndex(COLUMN_MAX_SUPPORTED_REMOTE_ANDROID_SDK); + int maxSupportedRemoteAndroidSdkVersion = maxSdkCol >= 0 ? cursor.getInt(maxSdkCol) : 0; + if (NULL_STRING.equals(name)) name = null; if (NULL_STRING.equals(pairedBtAddress)) pairedBtAddress = null; - return new ConnectionConfiguration(name, pairedBtAddress, connectionType, role, enabled > 0, nodeId); + + ConnectionConfiguration c = new ConnectionConfiguration( + name, pairedBtAddress, connectionType, role, enabled > 0, + false, null, false, + nodeId, packageName, 0, null, false, + dataItemSyncEnabled, null, + removeConnectionWhenBondRemovedByUser, + null, + maxSupportedRemoteAndroidSdkVersion, 0 + ); + c.peerNodeId = peerNodeId; + c.connected = (peerNodeId != null); + return c; } public ConnectionConfiguration getConfiguration(String name) { @@ -77,45 +233,60 @@ public void putConfiguration(ConnectionConfiguration config) { public void putConfiguration(ConnectionConfiguration config, String oldNodeId) { ContentValues contentValues = new ContentValues(); + if (config.name != null) { - contentValues.put("name", config.name); + contentValues.put(COLUMN_NAME, config.name); } else if (config.role == 2) { - contentValues.put("name", "server"); + contentValues.put(COLUMN_NAME, "server"); } else { - contentValues.put("name", "NULL_STRING"); + contentValues.put(COLUMN_NAME, NULL_STRING); } + if (config.address != null) { - contentValues.put("pairedBtAddress", config.address); + contentValues.put(COLUMN_PAIRED_BT_ADDRESS, config.address); } else { - contentValues.put("pairedBtAddress", "NULL_STRING"); + contentValues.put(COLUMN_PAIRED_BT_ADDRESS, NULL_STRING); } - contentValues.put("connectionType", config.type); - contentValues.put("role", config.role); - contentValues.put("connectionEnabled", true); - contentValues.put("nodeId", config.nodeId); + + contentValues.put(COLUMN_PACKAGE_NAME, config.packageName); + contentValues.put(COLUMN_CONNECTION_TYPE, config.type); + contentValues.put(COLUMN_ROLE, config.role); + contentValues.put(COLUMN_CONNECTION_ENABLED, config.enabled ? 1 : 0); + contentValues.put(COLUMN_NODE_ID, config.nodeId); + contentValues.put(COLUMN_PACKAGE_NAME, config.packageName); + contentValues.put(COLUMN_PEER_NODE_ID, config.peerNodeId); + contentValues.put(COLUMN_DATA_ITEM_SYNC_ENABLED, config.dataItemSyncEnabled ? 1 : 0); + contentValues.put(COLUMN_REMOVE_CONNECTION_WHEN_BOND_REMOVED, config.removeConnectionWhenBondRemovedByUser ? 1 : 0); + contentValues.put(COLUMN_MAX_SUPPORTED_REMOTE_ANDROID_SDK, config.maxSupportedRemoteAndroidSdkVersion); + if (oldNodeId == null) { getWritableDatabase().insert(TABLE_NAME, null, contentValues); } else { - getWritableDatabase().update(TABLE_NAME, contentValues, "nodeId=?", new String[]{oldNodeId}); + getWritableDatabase().update(TABLE_NAME, contentValues, COLUMN_NODE_ID + "=?", new String[]{oldNodeId}); } } public ConnectionConfiguration[] getAllConfigurations() { Cursor cursor = getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null); if (cursor != null) { - List configurations = new ArrayList(); - while (cursor.moveToNext()) { - configurations.add(configFromCursor(cursor)); + try { + List configurations = new ArrayList<>(); + while (cursor.moveToNext()) { + configurations.add(configFromCursor(cursor)); + } + return configurations.toArray(new ConnectionConfiguration[0]); + } finally { + cursor.close(); } - cursor.close(); - return configurations.toArray(new ConnectionConfiguration[configurations.size()]); - } else { - return null; } + return new ConnectionConfiguration[0]; } - public void setEnabledState(String name, boolean enabled) { - getWritableDatabase().execSQL("UPDATE connectionConfigurations SET connectionEnabled=? WHERE name=?", new String[]{enabled ? "1" : "0", name}); + public void setEnabledState(String packageName, boolean enabled) { + ContentValues values = new ContentValues(); + values.put(COLUMN_CONNECTION_ENABLED, enabled ? 1 : 0); + getWritableDatabase().updateWithOnConflict(TABLE_NAME, values, BY_NAME, new String[]{packageName != null ? packageName : NULL_STRING}, SQLiteDatabase.CONFLICT_REPLACE); + } public int deleteConfiguration(String name) { diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConnectHandshake.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConnectHandshake.java new file mode 100644 index 0000000000..4cbfeb2d2f --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConnectHandshake.java @@ -0,0 +1,165 @@ +package org.microg.gms.wearable; + +import android.content.OperationApplicationException; +import android.text.TextUtils; +import android.util.Log; + +import com.google.android.gms.wearable.Wearable; + +import org.microg.gms.profile.Build; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; + +public class ConnectHandshake { + private static final String TAG = "WearConnectHandshake"; + + public static final int PEER_VERSION = 1; // still not sure if 2 or 1, but set to what in gms(maybe) + public static final int PEER_MIN_VERSION = 0; + + private ConnectHandshake() {} + + public static final class LocalIdentity { + public final String nodeId; + public final String nodeName; + public final long androidId; + public final String networkId; + public final String packageName; + public final boolean migrating; + public final String migratingFromNodeId; + + public LocalIdentity(String nodeId, String nodeName, long androidId, String networkId, + String packageName, boolean migrating, String migratingFromNodeId) { + this.nodeId = nodeId; + this.nodeName = nodeName; + this.androidId = androidId; + this.networkId = networkId; + this.packageName = packageName; + this.migrating = migrating; + this.migratingFromNodeId = migratingFromNodeId; + } + } + + public static Connect perform(WearableConnection connection, LocalIdentity local) + throws IOException { + connection.writeMessage(new RootMessage.Builder().connect(build(local)).build()); + + return readAndValidatePeerConnect(connection, local); + } + + public static Connect perform( + WearableConnection connection, + String localNodeId, String localNodeName, + long androidId, boolean isMigrating, + String migratingFrom + ) throws IOException { + return perform(connection, new LocalIdentity(localNodeId, localNodeName, androidId, + null, null, isMigrating, migratingFrom)); + } + + public static Connect perform( + WearableConnection connection, + String localNodeId, String localNodeName, + long androidId + ) throws IOException { + return perform(connection, localNodeId, localNodeName, + androidId, false, null); + } + + public static Connect build(LocalIdentity local) { + Connect.Builder cb = new Connect.Builder() + .id(local.nodeId) + .name(local.nodeName != null ? local.nodeName : local.nodeId) + .peerAndroidId(local.androidId) + .unknown4(3) + .peerVersion(PEER_VERSION) + .peerMinimumVersion(PEER_MIN_VERSION) + .androidSdkVersion(Build.VERSION.SDK_INT); + + if (!TextUtils.isEmpty(local.networkId)) { + cb.networkId(local.networkId); + } + + if (!TextUtils.isEmpty(local.packageName)) { + cb.packageName(local.packageName); + } + + if (local.migrating) { + cb.migrating(true); + if (!TextUtils.isEmpty(local.migratingFromNodeId)) { + cb.migratingFromNodeId(local.migratingFromNodeId); + } + } + + return cb.build(); + } + + private static Connect readAndValidatePeerConnect( + WearableConnection connection, LocalIdentity local + ) throws IOException { + RootMessage incoming = connection.readMessage(); + Log.d(TAG, "readAndValidatePeerConnect: received=" + incoming); + if (incoming == null) { + throw new IOException("Null message during Connect handshake"); + } + if (incoming.connect == null) { + throw new IOException("Peer did not start with Connect message, got: " + incoming); + } + + Connect peer = incoming.connect; + + if (peer.id == null || peer.id.isEmpty()) { + throw new IOException("Peer sent an empty node id in Connect message"); + } + + checkVersionCompatibility(peer); + checkMigrationParity(peer, local.migrating); + checkNetworkId(peer, local); + checkMigrationSource(peer, local); + + connection.setPeerConnect(peer); + + Log.d(TAG, "readAndValidatePeerConnect: handshake complete, peerNodeId=" + peer.id + + " peerName=" + peer.name + " peerVersion=" + peer.peerVersion + "/" + peer.peerMinimumVersion); + return peer; + } + + private static void checkVersionCompatibility(Connect peer) throws IOException { + int peerVer = peer.peerVersion != null ? peer.peerVersion : 0; + int peerMinVer = peer.peerMinimumVersion != null ? peer.peerMinimumVersion : 0; + + boolean incompatible = (peerVer <= 0) ? (peerVer < 0) : (peerMinVer > PEER_VERSION); + + if (incompatible) { + throw new IOException("Protocol version mismatch - version=" + PEER_VERSION + + "min=" + PEER_MIN_VERSION + "; peer version=" + peerVer + " min=" + peerMinVer); + } + } + + private static void checkMigrationParity(Connect peer, boolean migrating) throws IOException { + boolean peerMigrating = Boolean.TRUE.equals(peer.migrating); + if (migrating != peerMigrating) { + throw new IOException("isMigrating state mismatch: local=" + migrating + + " peer=" + peerMigrating); + } + } + + private static void checkNetworkId(Connect peer, LocalIdentity local) throws IOException { + if (local.migrating) return; + String peerNetworkId = peer.networkId; + if (TextUtils.isEmpty(peerNetworkId) || TextUtils.isEmpty(local.networkId)) return; + if (!local.networkId.equals(peerNetworkId)) { + throw new IOException("networkId mismatch - expected " + local.networkId + + " but peer advertised " + peerNetworkId); + } + } + + private static void checkMigrationSource(Connect peer, LocalIdentity local) throws IOException { + if (!local.migrating) return; + if (!TextUtils.isEmpty(local.migratingFromNodeId)) return; + if (TextUtils.isEmpty(peer.migratingFromNodeId)) { + throw new IOException("Peer is migrating but Connect is missing migratingFromNodeId"); + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConnectionRestrictionFilter.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConnectionRestrictionFilter.java new file mode 100644 index 0000000000..f726794ae3 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ConnectionRestrictionFilter.java @@ -0,0 +1,94 @@ +package org.microg.gms.wearable; + +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +import com.google.android.gms.wearable.ConnectionRestrictions; +import com.google.android.gms.wearable.DataItemFilter; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class ConnectionRestrictionFilter { + private static final String TAG = "WearRestrictions"; + + public static final int FILTER_UNSPECIFIED = 1; + public static final int FILTER_PATTERN_LITERAL = 2; + public static final int FILTER_PATTERN_PREFIX = 3; + + private ConnectionRestrictionFilter() {} + + public static boolean isValid(ConnectionRestrictions restrictions) { + if (restrictions == null) return true; + List filters = restrictions.allowedDataItemFilters; + if (filters == null) return true; + Set seen = new HashSet<>(); + for (DataItemFilter filter : filters) { + if (filter == null || filter.uri == null) { + Log.w(TAG, "Restriction set contains a null filter uri"); + return false; + } + if (!"wear".equals(filter.uri.getScheme())) { + Log.w(TAG, "Restriction set contains non-wear uri: " + filter.uri); + return false; + } + if (!seen.add(filter.uri + "#" + filter.filterType)) { + Log.w(TAG, "Restriction set contains a duplicate filter: " + filter.uri); + return false; + } + } + return true; + } + + public static boolean isPackageAllowed(ConnectionRestrictions restrictions, String packageName) { + if (restrictions == null || restrictions.allowedPackages == null) return true; + return packageName != null && restrictions.allowedPackages.contains(packageName); + } + + public static boolean isCapabilityAllowed(ConnectionRestrictions restrictions, String capability) { + if (restrictions == null || restrictions.allowedCapabilities == null) return true; + return capability != null && restrictions.allowedCapabilities.contains(capability); + } + + public static boolean isDataItemAllowed(ConnectionRestrictions restrictions, + String packageName, Uri uri) { + if (restrictions == null) return true; + if (!isPackageAllowed(restrictions, packageName)) { + return false; + } + List filters = restrictions.allowedDataItemFilters; + if (filters == null) return true; + if (uri == null) return false; + for (DataItemFilter filter : filters) { + if (matches(filter, uri)) return true; + } + return false; + } + + private static boolean matches(DataItemFilter filter, Uri uri) { + if (filter == null || filter.uri == null) return false; + + String filterHost = filter.uri.getAuthority(); + if (!TextUtils.isEmpty(filterHost) + && !filterHost.equals(uri.getAuthority()) + && !"*".equals(filterHost)) { + return false; + } + + String filterPath = filter.uri.getPath(); + String path = uri.getPath(); + if (TextUtils.isEmpty(filterPath)) return true; + if (path == null) return false; + + if (filter.filterType == FILTER_PATTERN_LITERAL) { + return filterPath.equals(path); + } + + if (!path.startsWith(filterPath)) return false; + return path.length() == filterPath.length() + || filterPath.endsWith("/") + || path.charAt(filterPath.length()) == '/'; + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/DataItemRecord.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/DataItemRecord.java index 06052128c0..0032dc607e 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/DataItemRecord.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/DataItemRecord.java @@ -26,8 +26,8 @@ import com.google.android.gms.wearable.internal.DataItemAssetParcelable; import com.google.android.gms.wearable.internal.DataItemParcelable; -import org.microg.wearable.proto.AssetEntry; -import org.microg.wearable.proto.SetDataItem; +import org.microg.gms.wearable.proto.AssetEntry; +import org.microg.gms.wearable.proto.SetDataItem; import java.util.ArrayList; import java.util.HashMap; @@ -37,7 +37,7 @@ import okio.ByteString; public class DataItemRecord { - private static String[] EVENT_DATA_HOLDER_FIELDS = new String[] { "event_type", "path", "data", "tags", "asset_key", "asset_id" }; + private static final String[] EVENT_DATA_HOLDER_FIELDS = new String[] { "event_type", "path", "data", "tags", "asset_key", "asset_id" }; public DataItemInternal dataItem; public String source; @@ -120,7 +120,7 @@ public SetDataItem toSetDataItem() { protoAssets.add(new AssetEntry.Builder() .key(key) .unknown3(4) - .value(new org.microg.wearable.proto.Asset.Builder() + .value(new org.microg.gms.wearable.proto.Asset.Builder() .digest(assets.get(key).getDigest()) .build()).build()); } @@ -139,14 +139,24 @@ public static DataItemRecord fromCursor(Cursor cursor) { record.dataItem.data = cursor.getBlob(8); record.lastModified = cursor.getLong(9); record.assetsAreReady = cursor.getLong(10) > 0; - if (cursor.getString(11) != null) { - record.dataItem.addAsset(cursor.getString(11), Asset.createFromRef(cursor.getString(12))); - while (cursor.moveToNext()) { - if (cursor.getLong(5) == record.seqId) { - record.dataItem.addAsset(cursor.getString(11), Asset.createFromRef(cursor.getString(12))); + if (cursor.getColumnCount() >= 12) { + String assetName = cursor.getString(11); + String assetDigest = cursor.getString(12); + if (assetName != null && !assetName.isEmpty() && assetDigest != null && !assetDigest.isEmpty()) { + record.dataItem.addAsset(assetName, Asset.createFromRef(assetDigest)); + while (cursor.moveToNext()) { + if (cursor.getLong(5) == record.seqId) { + String an = cursor.getString(11); + String ad = cursor.getString(12); + if (an != null && !an.isEmpty() && ad != null && !ad.isEmpty()) { + record.dataItem.addAsset(an, Asset.createFromRef(ad)); + } + } } + cursor.moveToPrevious(); } - cursor.moveToPrevious(); + } else { + Log.w("DataItemRecord", "Cursor missing asset columns (11,12), skipping asset loading"); } return record; } @@ -164,7 +174,7 @@ public static DataItemRecord fromSetDataItem(SetDataItem setDataItem) { record.seqId = setDataItem.seqId; record.v1SeqId = -1; record.lastModified = setDataItem.lastModified; - record.deleted = setDataItem.deleted == null ? false : setDataItem.deleted; + record.deleted = setDataItem.deleted; record.packageName = setDataItem.packageName; record.signatureDigest = setDataItem.signatureDigest; return record; diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/DataTransport.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/DataTransport.java new file mode 100644 index 0000000000..37845929fa --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/DataTransport.java @@ -0,0 +1,354 @@ +package org.microg.gms.wearable; + +import android.util.Log; + +import com.google.android.gms.wearable.Channel; +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.channel.ChannelManager; +import org.microg.gms.wearable.proto.RootMessage; +import org.microg.gms.wearable.proto.SyncStart; +import org.microg.gms.wearable.proto.SyncTableEntry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +public class DataTransport { + private static final String TAG = "WearDataTransport"; + + public enum DataSyncMode { + SYNC_ENABLED, + SYNC_DISABLED_UNTIL_STARTED, + SYNC_DISABLED_UNTIL_MESSAGE_RECEIVED + } + + public final String localNodeId; + public final String peerNodeId; + + private final WearableImpl wearable; + private final Object lock = new Object(); + + private WearableWriter writer; + + private Map peerSeqIds = new HashMap<>(); + + private Thread syncThread; + private final AtomicBoolean initialSyncFinished = new AtomicBoolean(false); + + private volatile SyncStart pendingSyncStart = null; + private volatile DataSyncMode dataSyncMode = DataSyncMode.SYNC_ENABLED; + private volatile boolean isV1Peer = false; + + public DataTransport(String localNodeId, String peerNodeId, WearableImpl wearable) { + this.wearable = wearable; + this.localNodeId = localNodeId; + this.peerNodeId = peerNodeId; + } + + public long getSeqIdForSource(String sourceNodeId) { + synchronized (lock) { + Long l = peerSeqIds.get(sourceNodeId); + return l != null ? l : -1L; + } + } + + public long updateSeqIdForSource(String sourceNodeId, long seqId) { + synchronized (lock) { + Long current = peerSeqIds.get(sourceNodeId); + if (current == null || seqId > current) { + peerSeqIds.put(sourceNodeId, seqId); + return seqId; + } + return current; + } + } + + public void onConnected(WearableWriter writer) { + onConnected(writer, DataSyncMode.SYNC_ENABLED); + } + + public void onConnected(WearableWriter writer, DataSyncMode mode) { + DataSyncMode effective = (mode != null) ? mode : DataSyncMode.SYNC_ENABLED; + synchronized (lock) { + this.writer = writer; + this.dataSyncMode = effective; + this.initialSyncFinished.set(false); + } + + if (effective == DataSyncMode.SYNC_ENABLED) + sendSyncStart(); + else + Log.d(TAG, "onConnected: SyncStart withheld, mode=" + effective + + " peer=" + peerNodeId); + } + + public void onDisconnect() { + WearableWriter prevWriter; + Thread prev; + synchronized (lock) { + prev = this.syncThread; + prevWriter = this.writer; + this.syncThread = null; + this.writer = null; + this.initialSyncFinished.set(false); + this.peerSeqIds = new HashMap<>(); + this.pendingSyncStart = null; + this.dataSyncMode = DataSyncMode.SYNC_ENABLED; + } + if (prevWriter != null) prevWriter.close(); + if (prev != null) prev.interrupt(); + Log.d(TAG, "onDisconnect: node=" + localNodeId + " peer=" + peerNodeId); + } + + public void sendSyncStart() { + WearableWriter currentWriter; + synchronized (lock){ + currentWriter = this.writer; + } + + if (currentWriter == null) { + Log.w(TAG, "sendSyncStart: no writer for peer=" + peerNodeId); + return; + } + + Map allSeqIds = wearable.getNodeDatabase().getAllCurrentSeqIds(); + Set activePeers = wearable.getConnectedPeerNodeIds(); + Map seqIds = new HashMap<>(); + + for (Map.Entry e : allSeqIds.entrySet()) { + String src = e.getKey(); + if (src != null && (src.equals(localNodeId) || activePeers.contains(src))) + seqIds.put(src, e.getValue()); + } + + List table = new ArrayList<>(seqIds.size()); + for (Map.Entry e : seqIds.entrySet()) { + table.add( + new SyncTableEntry.Builder() + .key(e.getKey()) + .value(e.getValue()) + .build() + ); + } + + Long receivedSeqId = seqIds.containsKey(peerNodeId) ? seqIds.get(peerNodeId) : -1L; + + SyncStart syncStart = new SyncStart.Builder() + .receivedSeqId(receivedSeqId) + .version(2) + .syncTable(table) + .build(); + + currentWriter.enqueue(new RootMessage.Builder().syncStart(syncStart).build()); + + Log.d(TAG, "sendSyncStart enqueued: node=" + localNodeId + " peer=" + peerNodeId + + " receivedSeqId=" + receivedSeqId + " entries=" + table.size()); + } + + public void respondToSyncStart(SyncStart syncStart) { + synchronized (lock) { + if (dataSyncMode == DataSyncMode.SYNC_DISABLED_UNTIL_STARTED) { + Log.d(TAG, "respondToSyncStart: parked (sync disabled) for " + peerNodeId); + pendingSyncStart = syncStart; + return; + } + + if (dataSyncMode == DataSyncMode.SYNC_DISABLED_UNTIL_MESSAGE_RECEIVED) { + Log.d(TAG, "respondToSyncStart: enabling sync because SyncStart arrived for " + peerNodeId); + pendingSyncStart = syncStart; + onSyncEnabledLocked(); + return; + } + + doRespondToSyncStart(syncStart); + } + } + + public void onSyncEnabled() { + synchronized (lock) { + onSyncEnabledLocked(); + } + } + + private void onSyncEnabledLocked() + { + if (dataSyncMode == DataSyncMode.SYNC_ENABLED || writer == null) + return; + + dataSyncMode = DataSyncMode.SYNC_ENABLED; + SyncStart pending = pendingSyncStart; + pendingSyncStart = null; + sendSyncStart(); + + if (pending != null) + doRespondToSyncStart(pending); + } + + private void doRespondToSyncStart(SyncStart syncStart) { + if (syncStart == null) return; + + boolean v1 = (syncStart.version == null || syncStart.version < 2); + this.isV1Peer = v1; + + Map remote = new HashMap<>(); + if (syncStart.syncTable != null) { + for (SyncTableEntry e : syncStart.syncTable) { + if (e.key != null && e.value != null) { + remote.put(e.key, e.value); + } + } + } + + Log.d(TAG, "respondToSyncStart: node=" + localNodeId + " peer=" + peerNodeId + + " version=" + syncStart.version + " isV1=" + v1 + " remoteEntries=" + remote.size()); + + WearableWriter currentWriter; + synchronized (lock) { + this.peerSeqIds = remote; + currentWriter = this.writer; + } + + if (currentWriter == null) { + Log.w(TAG, "respondToSyncStart: no writer for perr=" + peerNodeId); + return; + } + + startSyncThread(new HashMap<>(remote)); + } + + private void startSyncThread(Map remotePeers) { + String name = "SyncStart" + (isV1Peer ? "V1" : "V2") + "-" + peerNodeId; + + Thread newThread = new Thread(() -> runSync(remotePeers), name); + + Thread prev; + synchronized (lock) { + prev = this.syncThread; + this.syncThread = newThread; + } + if (prev != null) prev.interrupt(); + newThread.start(); + Log.d(TAG, "Sync thread started: " + name); + } + + private void runSync(Map remotePeerSeqIds) { + Set activePeers = wearable.getConnectedPeerNodeIds(); + Log.d(TAG, "runSync start: peer=" + peerNodeId + + " activePeers=" + activePeers.size()); + try { + Map localSeqIds = wearable.getNodeDatabase().getAllCurrentSeqIds(); + int synced = 0; + int skipped = 0; + int ghostsExcluded = 0; + int echoSkipped = 0; + for (Map.Entry local : localSeqIds.entrySet()) { + if (Thread.currentThread().isInterrupted()) { + Log.d(TAG, "runSync interrupted for peer=" + peerNodeId); + return; + } + + String src = local.getKey(); + + if (src == null || (!src.equals(localNodeId) && !activePeers.contains(src))) { + ghostsExcluded++; + continue; + } + + if (src.equals(peerNodeId)){ + echoSkipped++; + continue; + } + + long peer = remotePeerSeqIds.containsKey(src) ? remotePeerSeqIds.get(src) : -1; + long localMax = local.getValue() != null ? local.getValue() : -1; + + if (localMax <= peer) { + skipped++; + continue; + } + + if (hasActiveChannelForPeer()) { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + + wearable.syncToPeer(peerNodeId, src, peer); + synced++; + } + Log.d(TAG, "runSync: peer=" + peerNodeId + + "synced=" + synced + " upToDate=" + skipped + + " ghostsExcluded=" + ghostsExcluded + + " selfEchoSkipped=" + echoSkipped); + } catch (Exception e) { + Log.w(TAG, "runSync exception for peer=" + peerNodeId, e); + } finally { + initialSyncFinished.set(true); + Log.d(TAG, "runSync done: peer=" + peerNodeId); + } + } + + private boolean hasActiveChannelForPeer() { + ChannelManager cm = wearable.getChannelManager(); + if (cm == null) + return false; + return cm.hasActiveChannelForNode(peerNodeId); + } + + public boolean sendDataItem(DataItemRecord record) { + long seqId = isV1Peer ? record.v1SeqId : record.seqId; + String src = (record.source != null) ? record.source : localNodeId; + synchronized (lock) { + WearableWriter currWriter = this.writer; + + if (currWriter == null) { + Log.w(TAG, "sendDataItem: no writer for peer=" + peerNodeId); + return false; + } + + boolean enqueued = currWriter.enqueue( + new RootMessage.Builder() + .setDataItem(record.toSetDataItem()) + .build() + ); + + if (!enqueued) { + Log.w(TAG, "sendDataItem: writer closed for peer=" + peerNodeId + + " src=" + src + " seqId=" + seqId + + " — message dropped, NOT advancing watermark"); + return false; + } + + updateSeqIdForSource(src, seqId); + } + Log.d(TAG, "sendDataItem enqueued: peer=" + peerNodeId + " src=" + src + " seqId" + seqId); + return true; + } + + public boolean isInitialSyncFinished() { + return initialSyncFinished.get(); + } + + public boolean isV1Peer() { + return isV1Peer; + } + + public void onDataItemSyncEnabled() { + onSyncEnabled(); + } + + public void onPeerChannelClosed() {} + + @Override + public String toString() { + return "DataTransport{local="+ localNodeId + ", peer=" + peerNodeId + + ", isV1=" + isV1Peer + ", syncDone=" + initialSyncFinished.get() + "}"; + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java index 0f12d92edd..bd46dcf367 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java @@ -16,93 +16,270 @@ package org.microg.gms.wearable; +import static org.microg.gms.wearable.WearableConnection.calculateDigest; +import static org.microg.gms.wearable.WearableImpl.ROLE_CLIENT; +import static org.microg.gms.wearable.WearableImpl.ROLE_SERVER; +import static org.microg.gms.wearable.WearableImpl.TYPE_BLE; +import static org.microg.gms.wearable.WearableImpl.TYPE_BLUETOOTH_RFCOMM; +import static org.microg.gms.wearable.WearableImpl.TYPE_NETWORK; +import static org.microg.gms.wearable.WearableServiceImpl.DATA_SYNC_PROGRESS_PATH; + import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; import android.text.TextUtils; import android.util.Log; import com.google.android.gms.wearable.Asset; import com.google.android.gms.wearable.ConnectionConfiguration; +import com.google.android.gms.wearable.MessageOptions; import com.google.android.gms.wearable.internal.MessageEventParcelable; +import org.microg.gms.common.Utils; import org.microg.gms.profile.Build; import org.microg.gms.settings.SettingsContract; -import org.microg.wearable.ServerMessageListener; -import org.microg.wearable.proto.AckAsset; -import org.microg.wearable.proto.Connect; -import org.microg.wearable.proto.FetchAsset; -import org.microg.wearable.proto.FilePiece; -import org.microg.wearable.proto.Heartbeat; -import org.microg.wearable.proto.Request; -import org.microg.wearable.proto.RootMessage; -import org.microg.wearable.proto.SetAsset; -import org.microg.wearable.proto.SetDataItem; -import org.microg.wearable.proto.SyncStart; -import org.microg.wearable.proto.SyncTableEntry; +import org.microg.gms.wearable.channel.ChannelManager; +import org.microg.gms.wearable.proto.AckAsset; +import org.microg.gms.wearable.proto.ControlMessage; +import org.microg.gms.wearable.proto.DataSyncTrackingMessage; +import org.microg.gms.wearable.proto.EncryptionHandshake; +import org.microg.gms.wearable.proto.AppKey; +import org.microg.gms.wearable.proto.AssetEntry; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.FetchAsset; +import org.microg.gms.wearable.proto.FilePiece; +import org.microg.gms.wearable.proto.Heartbeat; +import org.microg.gms.wearable.proto.Request; +import org.microg.gms.wearable.proto.RootMessage; +import org.microg.gms.wearable.proto.SetAsset; +import org.microg.gms.wearable.proto.SetDataItem; +import org.microg.gms.wearable.proto.SyncStart; +import org.microg.gms.wearable.proto.SyncTableEntry; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import okio.ByteString; public class MessageHandler extends ServerMessageListener { - private static final String TAG = "GmsWearMsgHandler"; + private static final String TAG = "WearMessageHandler"; private final WearableImpl wearable; private final String oldConfigNodeId; + private final ConnectionConfiguration config; + private final AccountMatching accountMatching; private String peerNodeId; - public MessageHandler(Context context, WearableImpl wearable, ConnectionConfiguration config) { - this(wearable, config, Build.MODEL, config.nodeId, SettingsContract.getSettings(context, SettingsContract.CheckIn.INSTANCE.getContentUri(context), new String[]{SettingsContract.CheckIn.ANDROID_ID}, cursor -> cursor.getLong(0))); - } - - private MessageHandler(WearableImpl wearable, ConnectionConfiguration config, String name, String networkId, long androidId) { - super(new Connect.Builder() - .name(name) - .id(wearable.getLocalNodeId()) - .networkId(networkId) - .peerAndroidId(androidId) - .unknown4(3) - .peerVersion(1) - .build()); + public MessageHandler(Context ctx, WearableImpl wearable, ConnectionConfiguration config) { + super(buildConnect(ctx, wearable, config)); this.wearable = wearable; + this.config = config; this.oldConfigNodeId = config.nodeId; + this.peerNodeId = config.peerNodeId; + this.accountMatching = new AccountMatching(wearable); + } + + private static Connect buildConnect(Context ctx, WearableImpl wearable, + ConnectionConfiguration config) { + long androidId = SettingsContract.getSettings(ctx, + SettingsContract.CheckIn.INSTANCE.getContentUri(ctx), + new String[]{SettingsContract.CheckIn.ANDROID_ID}, + c -> c.getLong(0)); + + String migratingFrom = null; + if (config.migrating && config.role == ROLE_SERVER) { + migratingFrom = wearable.getClockworkNodePreferences().getPeerNodeId(); + if (migratingFrom != null) { + Log.i(TAG, "Migration handshake: migratingFromNodeId=" + migratingFrom); + } else { + Log.w(TAG, "Migration requested but no previous peer nodeId stored"); + } + } + + return ConnectHandshake.build(new ConnectHandshake.LocalIdentity( + wearable.getLocalNodeId(), + Build.MODEL, // TODO: Should be hostname, but seems to be irrelevant + androidId, + wearable.getClockworkNodePreferences().getNetworkId(), + (config.type == TYPE_NETWORK && config.role == ROLE_CLIENT) + ? config.packageName : null, + config.migrating, + migratingFrom)); } @Override public void onConnect(Connect connect) { super.onConnect(connect); peerNodeId = connect.id; - wearable.onConnectReceived(getConnection(), oldConfigNodeId, connect); - try { - getConnection().writeMessage(new RootMessage.Builder().syncStart(new SyncStart.Builder() - .receivedSeqId(-1L) - .version(2) - .syncTable(Arrays.asList( - new SyncTableEntry.Builder().key("cloud").value(1L).build(), - new SyncTableEntry.Builder().key(wearable.getLocalNodeId()).value(wearable.getCurrentSeqId(wearable.getLocalNodeId())).build(), // TODO - new SyncTableEntry.Builder().key(peerNodeId).value(wearable.getCurrentSeqId(peerNodeId)).build() // TODO - )).build()).build()); - } catch (IOException e) { - Log.w(TAG, e); + Log.d(TAG, "onConnect: " + connect); + + if (config.migrating) { + Log.d(TAG, "config.migrating..."); + if (!Boolean.TRUE.equals(connect.migrating)) { + Log.e(TAG, "Migration state mismatch: local=true, peer=false for node " + + peerNodeId + ". Aborting."); + try { + getConnection().close(); + } catch (IOException ignored) { + } + return; + } + + if (config.role == ROLE_CLIENT) { + String migratingFrom = connect.migratingFromNodeId; + if (TextUtils.isEmpty(migratingFrom)) { + Log.e(TAG, "Attempting to migrate but Connect is missing migratingFromNodeId"); + try { + getConnection().close(); + } catch (IOException ignored) { + } + return; + } + Log.i(TAG, "Starting migration: node=" + peerNodeId + + " migratingFrom=" + migratingFrom); + wearable.startNodeMigration(peerNodeId, migratingFrom); + } + } else if (Boolean.TRUE.equals(connect.migrating)) { + Log.e(TAG, "Migration state mismatch: local=false, peer=true for node " + + peerNodeId + ". Aborting."); + try { + getConnection().close(); + } catch (IOException ignored) { + } + return; + } + + if (connect.peerAndroidId != null) { + wearable.getClockworkNodePreferences().setPeerAndroidId(connect.peerAndroidId); + } + + if (config.role == ROLE_SERVER + && (config.type == TYPE_BLUETOOTH_RFCOMM || config.type == TYPE_BLE)) { + String storedPeerId = wearable.getClockworkNodePreferences().getPeerNodeId(); + if (storedPeerId == null) { + Log.i(TAG, "onConnect: first pairing, storing peerNodeId=" + peerNodeId); + wearable.getClockworkNodePreferences().setPeerNodeId(peerNodeId); + } else if (!storedPeerId.equals(peerNodeId) && !config.migrating) { + Log.w(TAG, "onConnect: mismatched peerNodeId: stored=" + storedPeerId + + " incoming=" + peerNodeId + " - rejecting"); + try { + getConnection().close(); + } catch (IOException ignored) {} + return; + } + } else if (config.role == ROLE_SERVER) { + wearable.getClockworkNodePreferences().setPeerNodeId(peerNodeId); + } + + if (!wearable.getActiveConnections().containsKey(connect.id)) { + wearable.onConnectReceived(getConnection(), oldConfigNodeId, connect); + } else { + Log.d(TAG, "onConnect: connection already registered for " + connect.id + ", skipping onConnectReceived"); } } @Override public void onDisconnected() { + Log.d(TAG, "onDisconnected"); Connect connect = getRemoteConnect(); - if (connect == null) - connect = new Connect.Builder().id(oldConfigNodeId).name("Wear device").build(); - wearable.onDisconnectReceived(getConnection(), connect); + if (connect == null) { + String fallbackId = (peerNodeId != null && !peerNodeId.isEmpty()) ? peerNodeId : oldConfigNodeId; + String fallbackName = (peerNodeId != null && !peerNodeId.isEmpty()) ? peerNodeId : "Wear device"; + connect = new Connect.Builder().id(fallbackId).name(fallbackName).build(); + } + // wearable.onDisconnectReceived(getConnection(), connect); super.onDisconnected(); } @Override public void onSetAsset(SetAsset setAsset) { Log.d(TAG, "onSetAsset: " + setAsset); + if (setAsset.data == null && setAsset.digest != null) { + String fileName = WearableConnection.calculateDigest( + new RootMessage.Builder() + .setAsset(setAsset) + .hasAsset(true) + .build() + .encode()); + File staleTemp = wearable.createAssetReceiveTempFile(fileName); + if (staleTemp.exists()) { + Log.d(TAG, "onSetAsset: discarding stale partial transfer for " + fileName); + if (!staleTemp.delete()) { + Log.w(TAG, "onSetAsset: failed to delete stale temp file " + staleTemp); + } + } + } Asset asset; if (setAsset.data != null) { asset = Asset.createFromBytes(setAsset.data.toByteArray()); } else { asset = Asset.createFromRef(setAsset.digest); } - wearable.addAssetToDatabase(asset, setAsset.appkeys.appKeys); + List appKeys = (setAsset.appkeys != null && setAsset.appkeys.appKeys != null) + ? setAsset.appkeys.appKeys : Collections.emptyList(); + + wearable.addAssetToDatabase(asset, appKeys); + + if (setAsset.digest != null && setAsset.data != null) { + byte[] bytes = setAsset.data.toByteArray(); + File target = wearable.createAssetFile(setAsset.digest); + if (!target.exists()) { + if (!calculateDigest(bytes).equals(setAsset.digest)) { + Log.w(TAG, "onSetAsset: inline data digest mismatch for " + setAsset.digest); + return; + } + File tmp = new File(target.getParent(), target.getName() + ".tmp"); + try (FileOutputStream fos = new FileOutputStream(tmp)) { + fos.write(bytes); + } catch (IOException e) { + Log.w(TAG, "onSetAsset: failed to write inline asset", e); + tmp.delete(); + return; + } + if (!tmp.renameTo(target)) { + tmp.delete(); + return; + } + } + onAssetAvailable(setAsset.digest); + } + } + + private void onAssetAvailable(String digest) { + List nowReady = new ArrayList<>(); + synchronized (wearable.getNodeDatabase()) { + wearable.getNodeDatabase().markAssetAsPresent(digest); + wearable.getAssetFetcher().onAssetReceived(digest); + Cursor cursor = wearable.getNodeDatabase().getDataItemsWaitingForAsset(digest); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + DataItemRecord record = DataItemRecord.fromCursor(cursor); + if (record.assetsAreReady) continue; + boolean allPresent = true; + for (Asset a : record.dataItem.getAssets().values()) { + if (!wearable.assetFileExists(a.getDigest())) { + allPresent = false; + break; + } + } + if (!allPresent) continue; + record.assetsAreReady = true; + wearable.getNodeDatabase().updateAssetsReady(record.dataItem.uri.toString(), true); + nowReady.add(record); + } + } finally { + cursor.close(); + } + } + } + for (DataItemRecord r : nowReady) wearable.dispatchDataChanged(r); } @Override @@ -117,20 +294,18 @@ public void onFetchAsset(FetchAsset fetchAsset) { @Override public void onSyncStart(SyncStart syncStart) { - Log.d(TAG, "onSyncStart: " + syncStart); - if (syncStart.version < 2) { - Log.d(TAG, "Sync uses version " + syncStart.version + " which is not supported (yet)"); - } - boolean hasLocalNode = false; - if (syncStart.syncTable != null) { - for (SyncTableEntry entry : syncStart.syncTable) { - wearable.syncToPeer(peerNodeId, entry.key, entry.value); - if (wearable.getLocalNodeId().equals(entry.key)) hasLocalNode = true; - } + Log.d(TAG, "onSyncStart from " + peerNodeId + ": version=" + syncStart.version); + DataTransport dt = wearable.getDataTransport(peerNodeId); + if (dt != null) { + dt.respondToSyncStart(syncStart); } else { - Log.d(TAG, "No sync table given."); + Log.e(TAG, "onSyncStart: no DataTransport for " + peerNodeId); +// if (syncStart.syncTable != null) { +// for (SyncTableEntry e : syncStart.syncTable) { +// wearable.syncToPeer(peerNodeId, e.key, e.value); +// } +// } } - if (!hasLocalNode) wearable.syncToPeer(peerNodeId, wearable.getLocalNodeId(), 0); } @Override @@ -140,26 +315,141 @@ public void onSetDataItem(SetDataItem setDataItem) { } @Override - public void onRpcRequest(Request rpcRequest) { - Log.d(TAG, "onRpcRequest: " + rpcRequest); - if (TextUtils.isEmpty(rpcRequest.targetNodeId) || rpcRequest.targetNodeId.equals(wearable.getLocalNodeId())) { - MessageEventParcelable messageEvent = new MessageEventParcelable(); - messageEvent.data = rpcRequest.rawData != null ? rpcRequest.rawData.toByteArray() : null; - messageEvent.path = rpcRequest.path; - messageEvent.requestId = rpcRequest.requestId + 31 * (rpcRequest.generation + 527); - messageEvent.sourceNodeId = TextUtils.isEmpty(rpcRequest.sourceNodeId) ? peerNodeId : rpcRequest.sourceNodeId; - - wearable.sendMessageReceived(rpcRequest.packageName, messageEvent); - } else if (rpcRequest.targetNodeId.equals(peerNodeId)) { - // Drop it - } else { - // TODO: find next hop + public void onRpcRequest(Request rawRequest) { + Log.d(TAG, "onRpcRequest: " + rawRequest); + + if (rawRequest.request != null) { + ChannelManager channelManager = wearable.getChannelManager(); + if (channelManager != null) { + channelManager.onChannelRequestReceived(getConnection(), peerNodeId, rawRequest); + } + return; } - try { - getConnection().writeMessage(new RootMessage.Builder().heartbeat(new Heartbeat()).build()); - } catch (IOException e) { - onDisconnected(); + + final RpcMessageTransport transport = wearable.getRpcTransport(); + final Request request = transport.normalizeInbound(peerNodeId, rawRequest); + + final String path = request.path; + final String packageName = request.packageName; + final String sourceNodeId = TextUtils.isEmpty(request.sourceNodeId) ? peerNodeId : request.sourceNodeId; + + if (!Boolean.TRUE.equals(request.requiresResponse) && request.senderRequestId != null && peerNodeId != null) { + + byte[] responseData = request.rawData != null ? request.rawData.toByteArray() : null; + + boolean consumed = wearable.getRpcHelper().deliverRpcResponse(peerNodeId, request.senderRequestId, responseData); + + if (consumed) { + return; + } + + Log.w(TAG, "onRpcRequest: unmatched response " + + "senderRequestId=" + request.senderRequestId + + " path=" + path + " peer=" + peerNodeId); } + + if (Boolean.TRUE.equals(request.requiresResponse) + && request.requestId != null + && peerNodeId != null && path != null && transport.isForLocalNode(request)) { + + final int generation = request.generation != null ? request.generation : 0; + + final int requestId = request.requestId; + + final WearableImpl.PendingRpcRequest pending = new WearableImpl.PendingRpcRequest( + RpcHelper.combineId(generation, requestId), + generation, path, peerNodeId, packageName != null ? packageName : "", + request.signatureDigest != null ? request.signatureDigest : "", getConnection()); + + if (!TextUtils.isEmpty(packageName) && !TextUtils.isEmpty(sourceNodeId) + && (TextUtils.isEmpty(request.targetNodeId) + || request.targetNodeId.equals(wearable.getLocalNodeId()))) { + final MessageEventParcelable event = new MessageEventParcelable( + pending.reqId, + path, + request.rawData != null ? request.rawData.toByteArray() : null, + sourceNodeId); + + final Intent intent = new Intent("com.google.android.gms.wearable.REQUEST_RECEIVED"); + + intent.setPackage(packageName); + intent.setData(new Uri.Builder().scheme("wear").authority(sourceNodeId).path(path).build()); + + final boolean dispatched = wearable.dispatchRpcRequest(intent, event, pending, () -> { + Log.d(TAG, "onRpcRequest: REQUEST_RECEIVED " + "listener declined path=" + path); + wearable.storePendingRpcRequest(pending); + sendMessageReceived(packageName, event); + }); + + if (dispatched) { + Log.d(TAG, "onRpcRequest: dispatched REQUEST_RECEIVED " + + "package=" + packageName + " path=" + path + " source=" + sourceNodeId); + return; + } + + Log.d(TAG, "onRpcRequest: no REQUEST_RECEIVED listener package=" + packageName + + " path=" + path); + } + + wearable.storePendingRpcRequest(pending); + + final String pendingPeer = peerNodeId; + final String pendingPath = path; + + wearable.networkHandler.postDelayed(() -> { + WearableImpl.PendingRpcRequest stillPending = wearable.consumePendingRpcRequest(pendingPeer, pendingPath); + + if (stillPending == null) { + return; + } + + Log.d(TAG, "onRpcRequest: auto-ACK path=" + pendingPath + " requestId=" + requestId); + + if (TextUtils.isEmpty(stillPending.packageName) || TextUtils.isEmpty(stillPending.signatureDigest)) { + Log.w(TAG, "onRpcRequest: auto-ACK skipped; " + "request lacks package/signatureDigest"); + return; + } + + try { + Request ack = transport.buildRequest( + stillPending.packageName, + stillPending.signatureDigest, + stillPending.peerNodeId, + stillPending.path, + null, null, false, + stillPending.reqId, null); + + stillPending.connection.writeMessage(RpcMessageTransport.wrap(ack)); + + } catch (IOException e) { + Log.w(TAG, "onRpcRequest: auto-ACK failed path=" + pendingPath, e); + } + }, 500); + + return; + } + + + if (TextUtils.isEmpty(request.targetNodeId) || request.targetNodeId.equals(wearable.getLocalNodeId())) { + final int requestId = request.requestId != null ? request.requestId : 0; + final byte[] data = request.rawData != null ? request.rawData.toByteArray() : null; + final String source = TextUtils.isEmpty(request.sourceNodeId) ? peerNodeId : request.sourceNodeId; + final MessageEventParcelable event = new MessageEventParcelable(requestId, path, data, source); + + sendMessageReceived(packageName, event); + } else if (request.targetNodeId.equals(peerNodeId)) { + // Drop it. + Log.d(TAG, "onRpcRequest: dropping packet addressed to peer " + request.targetNodeId); + } else { + Log.d(TAG, "onRpcRequest: packet requires routing target=" + request.targetNodeId + + " peer=" + peerNodeId); + } + } + + @Override + public void onRpcWithResponseId(Request rpcWithResponseId) { + Log.d(TAG, "onRpcWithResponseId: " + rpcWithResponseId); + onRpcRequest(rpcWithResponseId); } @Override @@ -170,11 +460,341 @@ public void onHeartbeat(Heartbeat heartbeat) { @Override public void onFilePiece(FilePiece filePiece) { Log.d(TAG, "onFilePiece: " + filePiece); - wearable.handleFilePiece(getConnection(), filePiece.fileName, filePiece.piece.toByteArray(), filePiece.finalPiece ? filePiece.digest : null); + handleFilePiece(getConnection(), filePiece.fileName, filePiece.piece.toByteArray(), filePiece.finalPiece ? filePiece.digest : null); } @Override public void onChannelRequest(Request channelRequest) { Log.d(TAG, "onChannelRequest:" + channelRequest); + if (wearable.getChannelManager() != null) { + wearable.getChannelManager().onChannelRequestReceived(getConnection(), peerNodeId, channelRequest); + } + } + + @Override + public void onEncryptionHandshake(EncryptionHandshake encryptionHandshake) { + Log.d(TAG, "onChannelRequest:" + encryptionHandshake); } + + @Override + public void onControlMessage(ControlMessage controlMessage) { + dispatchControlMessage(getConnection(), peerNodeId, controlMessage); + } + + public void handleMessage(WearableConnection connection, String sourceNodeId, RootMessage message) { + Log.d(TAG, "handleMessage from " + sourceNodeId); + + if (message.heartbeat != null) { + Log.d(TAG, "Received heartbeat from " + sourceNodeId); + return; + } + + if (message.controlMessage != null) { + Log.d(TAG, "handleMessage: controlMessage from " + sourceNodeId); + dispatchControlMessage(getConnection(), peerNodeId, message.controlMessage); + return; + } + + if (message.syncStart != null) { + Log.d(TAG, "message.syncStart..."); + handleSyncStart(connection, sourceNodeId, message.syncStart); + } + + if (message.channelRequest != null && wearable.getChannelManager() != null) { + Log.d(TAG, "message.channelRequest..."); + wearable.getChannelManager().onChannelRequestReceived(connection, sourceNodeId, message.channelRequest); + } + + if (message.rpcRequest != null) { + Log.d(TAG, "message.rpcRequest..."); + handleRpcRequest(connection, sourceNodeId, message.rpcRequest); + } + + if (message.setDataItem != null) { + Log.d(TAG, "message.setDataItem..."); + handleSetDataItem(connection, sourceNodeId, message.setDataItem); + } + + if (message.filePiece != null) { + Log.d(TAG, "message.filePiece..."); + FilePiece piece = message.filePiece; + handleFilePiece(connection, piece.fileName, + piece.piece != null ? piece.piece.toByteArray() : new byte[0], piece.finalPiece ? piece.digest : null); + } + + if (message.ackAsset != null) { + wearable.getAssetManager().onAckAsset(message.ackAsset.digest); + } + + if (message.fetchAsset != null) { + wearable.getAssetManager().handleFetchAsset(connection, sourceNodeId, message.fetchAsset); + } + + if (message.setAsset != null) { + wearable.getAssetManager().onAssetReceived(message.setAsset.digest); + } + } + + private void dispatchControlMessage(WearableConnection connection, + String sourceNodeId, ControlMessage ctrl) { + if (ctrl == null || ctrl.type == null) return; + + Log.d(TAG, "dispatchControlMessage: type=" + ctrl.type + " from=" + sourceNodeId); + + switch (ctrl.type) { + case NodeMigrationController.CTRL_TERMINATE_ASSOCIATION: + Log.i(TAG, "dispatchControlMessage: TERMINATE_ASSICUATION from " + sourceNodeId); + wearable.terminateAssociation(sourceNodeId, false, "peer requested"); + break; + + case NodeMigrationController.CTRL_SUSPEND_SYNC: + Log.i(TAG, "dispatchControlMessage: SUSPENDED_SYNC from " + sourceNodeId); + wearable.getMigrationController().suspendNode(sourceNodeId); + break; + + case NodeMigrationController.CTRL_RESUME_SYNC: + Log.i(TAG, "dispatchControlMessage: RESUME_SYNC from " + sourceNodeId); + wearable.getMigrationController().resumeNode(sourceNodeId); + wearable.triggerResync(sourceNodeId); + break; + + case NodeMigrationController.CTRL_MIGRATION_FAILED: + Log.w(TAG, "dispatchControlMessage: MIGRATION_FAILED from " + sourceNodeId); + wearable.onMigrationFailed(sourceNodeId, false); + break; + + case NodeMigrationController.CTRL_ACCOUNT_MATCHING: + accountMatching.handleControlMessage(connection, sourceNodeId, ctrl); + break; + + case NodeMigrationController.CTRL_MIGRATION_CANCELLED: + Log.w(TAG, "dispatchControlMessage: MIGRATION_CANCELLED from " + sourceNodeId); + wearable.onMigrationFailed(sourceNodeId, false); + break; + + default: + Log.w(TAG, "dispatchControlMessage: Unknown control message type=" + ctrl.type + + " from=" + sourceNodeId); + break; + } + } + + private void handleSyncStart(WearableConnection connection, String sourceNodeId, + org.microg.gms.wearable.proto.SyncStart syncStart) { + Log.d(TAG, "handleSyncStart from " + sourceNodeId + + ": receivedSeqId=" + syncStart.receivedSeqId + + ", version=" + syncStart.version); + + DataTransport dt = wearable.getDataTransport(sourceNodeId); + if (dt != null) { + dt.respondToSyncStart(syncStart); + } else { + Log.w(TAG, "onSyncStart: no DataTransport for " + sourceNodeId); +// if(syncStart.syncTable != null) { +// for (SyncTableEntry e : syncStart.syncTable) { +// wearable.syncToPeer(sourceNodeId, e.key, e.value); +// } +// } + } + } + + private void handleRpcRequest(WearableConnection connection, String sourceNodeId, Request request) { + Log.d(TAG, "handleRpcRequest from " + sourceNodeId + ": path=" + request.path); + + if (request.rawData != null) { + MessageEventParcelable messageEvent = new MessageEventParcelable( + request.requestId, + request.path, + request.rawData.toByteArray(), + sourceNodeId + ); + sendMessageReceived(request.packageName, messageEvent); + } + } + + private void handleSetDataItem(WearableConnection connection, String sourceNodeId, + SetDataItem setDataItem) { + Log.d(TAG, "handleSetDataItem from " + sourceNodeId + ": " + setDataItem.uri); + + DataItemRecord record = DataItemRecord.fromSetDataItem(setDataItem); + record.source = sourceNodeId; + + List missingAssets = new ArrayList<>(); + if (setDataItem.assets != null) { + for (AssetEntry assetEntry : setDataItem.assets) { + if (assetEntry.value != null && assetEntry.value.digest != null) { + String digest = assetEntry.value.digest; + if (!wearable.assetFileExists(digest)) { + missingAssets.add(Asset.createFromRef(digest)); + } + } + } + } + + record.assetsAreReady = missingAssets.isEmpty(); + + wearable.putDataItem(record); + + if (!missingAssets.isEmpty()) { + fetchMissingAssets(connection, record, missingAssets); + } + } + + private void fetchMissingAssets(WearableConnection connection, DataItemRecord record, + List missingAssets) { + wearable.getAssetFetcher().fetchMissingAssetsForRecord(connection, record, missingAssets); + } + + private void handleFetchAsset(WearableConnection connection, String sourceNodeId, + FetchAsset fetchAsset) { + Log.d(TAG, "handleFetchAsset: " + fetchAsset.assetName); + + File assetFile = wearable.createAssetFile(fetchAsset.assetName); + if (assetFile.exists()) { + try { + RootMessage announceMessage = new RootMessage.Builder() + .setAsset(new SetAsset.Builder() + .digest(fetchAsset.assetName) + .build()) + .hasAsset(true) + .build(); + connection.writeMessage(announceMessage); + + String fileName = calculateDigest(announceMessage.encode()); + FileInputStream fis = new FileInputStream(assetFile); + byte[] arr = new byte[12215]; + ByteString lastPiece = null; + int c; + while ((c = fis.read(arr)) > 0) { + if (lastPiece != null) { + connection.writeMessage(new RootMessage.Builder() + .filePiece(new FilePiece(fileName, false, lastPiece, null)) + .build()); + } + lastPiece = ByteString.of(arr, 0, c); + } + fis.close(); + connection.writeMessage(new RootMessage.Builder() + .filePiece(new FilePiece(fileName, true, lastPiece, fetchAsset.assetName)) + .build()); + } catch (IOException e) { + Log.e(TAG, "Failed to send asset", e); + } + } else { + Log.w(TAG, "Asset not found: " + fetchAsset.assetName); + } + } + + public void handleFilePiece(WearableConnection connection, String fileName, byte[] bytes, String finalPieceDigest) { + File file = wearable.createAssetReceiveTempFile(fileName); + try (FileOutputStream fos = new FileOutputStream(file, true)) { + fos.write(bytes); + } catch (IOException e) { + Log.w(TAG, "Error writing file piece", e); + } + + if (finalPieceDigest == null) { + return; + } + + try { + String digest; + try (FileInputStream in = new FileInputStream(file)) { + digest = calculateDigest(Utils.readStreamToEnd(in)); + } + + if (!digest.equals(finalPieceDigest)) { + Log.w(TAG, "Digest mismatch: expected=" + finalPieceDigest + + ", actual=" + digest + ". Deleting temp file."); + file.delete(); + wearable.getAssetFetcher().onAssetFetchFailed(finalPieceDigest); + return; + } + + File targetFile = wearable.createAssetFile(digest); + if (!file.renameTo(targetFile)) { + Log.w(TAG, "Failed to rename temp file to target. Deleting temp file."); + file.delete(); + wearable.getAssetFetcher().onAssetFetchFailed(finalPieceDigest); + return; + } + + Log.d(TAG, "Asset saved successfully: " + digest); + + try { + connection.writeMessage(new RootMessage.Builder() + .ackAsset(new AckAsset(digest)) + .build()); + } catch (IOException e) { + Log.w(TAG, "Failed to send asset ACK", e); + } + + onAssetAvailable(digest); + } catch (IOException e) { + Log.w(TAG, "Error processing final file piece", e); + file.delete(); + wearable.getAssetFetcher().onAssetFetchFailed(finalPieceDigest); + } + } + + public void sendMessageReceived(String packageName, MessageEventParcelable messageEvent) { + Log.d(TAG, "onMessageReceived: " + messageEvent); + if (DATA_SYNC_PROGRESS_PATH.equals(messageEvent.getPath())) { + handleDataSyncProgress(messageEvent); + return; + } + Intent intent = new Intent("com.google.android.gms.wearable.MESSAGE_RECEIVED"); + intent.setPackage(packageName); + intent.setData(new Uri.Builder().scheme("wear").authority(messageEvent.getSourceNodeId()).path(messageEvent.getPath()).build()); + wearable.invokeListeners(intent, listener -> listener.onMessageReceived(messageEvent)); + } + + private void handleDataSyncProgress(MessageEventParcelable messageEvent) { + byte[] data = messageEvent.getData(); + if (data != null && data.length > 0) { + try { + DataSyncTrackingMessage msg = + DataSyncTrackingMessage.ADAPTER.decode(data); + + if (!TextUtils.isEmpty(msg.trackerId)) { + boolean consumed = wearable.getRpcHelper().deliverDataSyncResponse( + messageEvent.getSourceNodeId(), + msg.trackerId, + msg.sequenceId != null ? msg.sequenceId : 0L); + + if (consumed) { + Log.d(TAG, "DATA_SYNC_PROGRESS: delivered response trackerId=" + + msg.trackerId); + return; + } + + long targetSeqId = msg.sequenceId != null ? msg.sequenceId : 0L; + long localSeqId = wearable.getNodeDatabase() + .getCurrentSeqId(messageEvent.getSourceNodeId()); + + Log.d(TAG, "DATA_SYNC_PROGRESS: received tracking request from " + + messageEvent.getSourceNodeId() + + " trackerId=" + msg.trackerId + + " targetSeqId=" + targetSeqId + + " localSeqId=" + localSeqId); + + DataSyncTrackingMessage response = new DataSyncTrackingMessage.Builder() + .trackerId(msg.trackerId) + .sequenceId(localSeqId) + .build(); + byte[] responsePayload = + DataSyncTrackingMessage.ADAPTER.encode(response); + wearable.sendMessage( + "", + messageEvent.getSourceNodeId(), + DATA_SYNC_PROGRESS_PATH, + responsePayload, + new MessageOptions(0)); + } + } catch (IOException e) { + Log.w(TAG, "DATA_SYNC_PROGRESS: failed to decode message", e); + } + } + } + } diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageListener.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageListener.java new file mode 100644 index 0000000000..8712284bab --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageListener.java @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2015, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import org.microg.gms.wearable.proto.AckAsset; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.ControlMessage; +import org.microg.gms.wearable.proto.EncryptionHandshake; +import org.microg.gms.wearable.proto.FetchAsset; +import org.microg.gms.wearable.proto.FilePiece; +import org.microg.gms.wearable.proto.Heartbeat; +import org.microg.gms.wearable.proto.Request; +import org.microg.gms.wearable.proto.RootMessage; +import org.microg.gms.wearable.proto.SetAsset; +import org.microg.gms.wearable.proto.SetDataItem; +import org.microg.gms.wearable.proto.SyncStart; + +public abstract class MessageListener implements WearableConnection.Listener { + private WearableConnection connection; + + @Override + public void onConnected(WearableConnection connection) { + this.connection = connection; + } + + @Override + public void onDisconnected() { + this.connection = null; + } + + public WearableConnection getConnection() { + return connection; + } + + @Override + public void onMessage(WearableConnection connection, RootMessage message) { + if (message.setAsset != null) { + onSetAsset(message.setAsset); + } else if (message.ackAsset != null) { + onAckAsset(message.ackAsset); + } else if (message.fetchAsset != null) { + onFetchAsset(message.fetchAsset); + } else if (message.connect != null) { + onConnect(message.connect); + } else if (message.syncStart != null) { + onSyncStart(message.syncStart); + } else if (message.setDataItem != null) { + onSetDataItem(message.setDataItem); + } else if (message.rpcRequest != null) { + onRpcRequest(message.rpcRequest); + } else if (message.rpcServiceRequest != null) { + onRpcWithResponseId(message.rpcServiceRequest); + } else if (message.heartbeat != null) { + onHeartbeat(message.heartbeat); + } else if (message.filePiece != null) { + onFilePiece(message.filePiece); + } else if (message.channelRequest != null) { + onChannelRequest(message.channelRequest); + } else if (message.e2eHandshake != null) { + onEncryptionHandshake(message.e2eHandshake); + } else if (message.controlMessage != null) { + onControlMessage(message.controlMessage); + } else { + System.err.println("Unknown message: " + message); + } + } + + public abstract void onSetAsset(SetAsset setAsset); + + public abstract void onAckAsset(AckAsset ackAsset); + + public abstract void onFetchAsset(FetchAsset fetchAsset); + + public abstract void onConnect(Connect connect); + + public abstract void onSyncStart(SyncStart syncStart); + + public abstract void onSetDataItem(SetDataItem setDataItem); + + public abstract void onRpcRequest(Request rpcRequest); + + public abstract void onRpcWithResponseId(Request rpcWithResponseId); + + public abstract void onHeartbeat(Heartbeat heartbeat); + + public abstract void onFilePiece(FilePiece filePiece); + + public abstract void onChannelRequest(Request channelRequest); + + public abstract void onEncryptionHandshake(EncryptionHandshake encryptionHandshake); + + public abstract void onControlMessage(ControlMessage controlMessage); +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java index 8b86fee197..7bf45116ef 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java @@ -27,6 +27,7 @@ import com.google.android.gms.wearable.Asset; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -35,9 +36,9 @@ public class NodeDatabaseHelper extends SQLiteOpenHelper { private static final String DB_NAME = "node.db"; private static final String[] GDIBHAP_FIELDS = new String[]{"dataitems_id", "packageName", "signatureDigest", "host", "path", "seqId", "deleted", "sourceNode", "data", "timestampMs", "assetsPresent", "assetname", "assets_digest", "v1SourceNode", "v1SeqId"}; - private static final int VERSION = 9; + private static final int VERSION = 14; - private ClockworkNodePreferences clockworkNodePreferences; + private final ClockworkNodePreferences clockworkNodePreferences; public NodeDatabaseHelper(Context context) { super(context, DB_NAME, null, VERSION); @@ -46,23 +47,186 @@ public NodeDatabaseHelper(Context context) { @Override public void onCreate(SQLiteDatabase db) { - db.execSQL("CREATE TABLE appkeys(_id INTEGER PRIMARY KEY AUTOINCREMENT,packageName TEXT NOT NULL,signatureDigest TEXT NOT NULL);"); - db.execSQL("CREATE TABLE dataitems(_id INTEGER PRIMARY KEY AUTOINCREMENT, appkeys_id INTEGER NOT NULL REFERENCES appkeys(_id), host TEXT NOT NULL, path TEXT NOT NULL, seqId INTEGER NOT NULL, deleted INTEGER NOT NULL, sourceNode TEXT NOT NULL, data BLOB, timestampMs INTEGER NOT NULL, assetsPresent INTEGER NOT NULL, v1SourceNode TEXT NOT NULL, v1SeqId INTEGER NOT NULL);"); - db.execSQL("CREATE TABLE assets(digest TEXT PRIMARY KEY, dataPresent INTEGER NOT NULL DEFAULT 0, timestampMs INTEGER NOT NULL);"); - db.execSQL("CREATE TABLE assetrefs(assetname TEXT NOT NULL, dataitems_id INTEGER NOT NULL REFERENCES dataitems(_id), assets_digest TEXT NOT NULL REFERENCES assets(digest));"); - db.execSQL("CREATE TABLE assetsacls(appkeys_id INTEGER NOT NULL REFERENCES appkeys(_id), assets_digest TEXT NOT NULL);"); - db.execSQL("CREATE TABLE nodeinfo(node TEXT NOT NULL PRIMARY KEY, seqId INTEGER, lastActivityMs INTEGER);"); - db.execSQL("CREATE VIEW appKeyDataItems AS SELECT appkeys._id AS appkeys_id, appkeys.packageName AS packageName, appkeys.signatureDigest AS signatureDigest, dataitems._id AS dataitems_id, dataitems.host AS host, dataitems.path AS path, dataitems.seqId AS seqId, dataitems.deleted AS deleted, dataitems.sourceNode AS sourceNode, dataitems.data AS data, dataitems.timestampMs AS timestampMs, dataitems.assetsPresent AS assetsPresent, dataitems.v1SourceNode AS v1SourceNode, dataitems.v1SeqId AS v1SeqId FROM appkeys, dataitems WHERE appkeys._id=dataitems.appkeys_id"); - db.execSQL("CREATE VIEW appKeyAcls AS SELECT appkeys._id AS appkeys_id, appkeys.packageName AS packageName, appkeys.signatureDigest AS signatureDigest, assetsacls.assets_digest AS assets_digest FROM appkeys, assetsacls WHERE _id=appkeys_id"); - db.execSQL("CREATE VIEW dataItemsAndAssets AS SELECT appKeyDataItems.packageName AS packageName, appKeyDataItems.signatureDigest AS signatureDigest, appKeyDataItems.dataitems_id AS dataitems_id, appKeyDataItems.host AS host, appKeyDataItems.path AS path, appKeyDataItems.seqId AS seqId, appKeyDataItems.deleted AS deleted, appKeyDataItems.sourceNode AS sourceNode, appKeyDataItems.data AS data, appKeyDataItems.timestampMs AS timestampMs, appKeyDataItems.assetsPresent AS assetsPresent, assetrefs.assetname AS assetname, assetrefs.assets_digest AS assets_digest, appKeyDataItems.v1SourceNode AS v1SourceNode, appKeyDataItems.v1SeqId AS v1SeqId FROM appKeyDataItems LEFT OUTER JOIN assetrefs ON appKeyDataItems.dataitems_id=assetrefs.dataitems_id"); - db.execSQL("CREATE VIEW assetsReadyStatus AS SELECT dataitems_id AS dataitems_id, COUNT(*) = SUM(dataPresent) AS nowReady, assetsPresent AS markedReady FROM assetrefs, dataitems LEFT OUTER JOIN assets ON assetrefs.assets_digest = assets.digest WHERE assetrefs.dataitems_id=dataitems._id GROUP BY dataitems_id;"); - db.execSQL("CREATE UNIQUE INDEX appkeys_NAME_AND_SIG ON appkeys(packageName,signatureDigest);"); - db.execSQL("CREATE UNIQUE INDEX assetrefs_ASSET_REFS ON assetrefs(assets_digest,dataitems_id,assetname);"); + db.execSQL("CREATE TABLE appkeys(" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "packageName TEXT NOT NULL, " + + "signatureDigest TEXT NOT NULL);"); + + db.execSQL("CREATE TABLE dataitems(" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "appkeys_id INTEGER NOT NULL REFERENCES appkeys(_id)," + + "host TEXT NOT NULL, " + + "path TEXT NOT NULL, " + + "seqId INTEGER NOT NULL, " + + "deleted INTEGER NOT NULL, " + + "sourceNode TEXT NOT NULL, " + + "data BLOB," + + "timestampMs INTEGER NOT NULL, " + + "assetsPresent INTEGER NOT NULL, " + + "v1SourceNode TEXT NOT NULL, " + + "v1SeqId INTEGER NOT NULL);"); + + db.execSQL("CREATE TABLE archiveDataItems(" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "migratingNode TEXT NOT NULL, " + + "appkeys_id INTEGER NOT NULL REFERENCES appkeys(_id), " + + "path TEXT NOT NULL, " + + "data BLOB, " + + "timestampMs INTEGER NOT NULL, " + + "assetsPresent INTEGER NOT NULL);"); + + db.execSQL("CREATE TABLE assets(" + + "digest TEXT PRIMARY KEY, " + + "dataPresent INTEGER NOT NULL DEFAULT 0, " + + "timestampMs INTEGER NOT NULL);"); + + db.execSQL("CREATE TABLE assetrefs(" + + "assetname TEXT NOT NULL, " + + "dataitems_id INTEGER NOT NULL REFERENCES dataitems(_id), " + + "assets_digest TEXT NOT NULL REFERENCES assets(digest));"); + + db.execSQL("CREATE TABLE archiveAssetRefs(" + + "assetname TEXT NOT NULL, " + + "archiveDataItems_id INTEGER NOT NULL REFERENCES dataitems(_id), " + + "assets_digest TEXT NOT NULL REFERENCES assets(digest));"); + + db.execSQL("CREATE TABLE assetsacls(" + + "appkeys_id INTEGER NOT NULL REFERENCES appkeys(_id), " + + "assets_digest TEXT NOT NULL);"); + + db.execSQL("CREATE TABLE nodeinfo(" + + "node TEXT NOT NULL PRIMARY KEY, " + + "seqId INTEGER, " + + "lastActivityMs INTEGER, " + + "migratingFrom TEXT DEFAULT NULL, " + + "enrollmentId TEXT DEFAULT NULL);"); + + db.execSQL("CREATE TABLE nodeMigration (" + + "nodeId TEXT NOT NULL PRIMARY KEY, " + + "migratingFromNodeId TEXT NOT NULL, " + + "complete INTEGER NOT NULL DEFAULT 0);"); + + db.execSQL("CREATE VIEW appKeyDataItems AS SELECT " + + "appkeys._id AS appkeys_id, " + + "appkeys.packageName AS packageName, " + + "appkeys.signatureDigest AS signatureDigest, " + + "dataitems._id AS dataitems_id, " + + "dataitems.host AS host, " + + "dataitems.path AS path, " + + "dataitems.seqId AS seqId, " + + "dataitems.deleted AS deleted, " + + "dataitems.sourceNode AS sourceNode, " + + "dataitems.data AS data, " + + "dataitems.timestampMs AS timestampMs, " + + "dataitems.assetsPresent AS assetsPresent, " + + "dataitems.v1SourceNode AS v1SourceNode, " + + "dataitems.v1SeqId AS v1SeqId " + + "FROM appkeys, dataitems " + + "WHERE appkeys._id=dataitems.appkeys_id"); + + db.execSQL("CREATE VIEW appKeyAcls AS SELECT " + + "appkeys._id AS appkeys_id, " + + "appkeys.packageName AS packageName, " + + "appkeys.signatureDigest AS signatureDigest, " + + "assetsacls.assets_digest AS assets_digest " + + "FROM appkeys, assetsacls " + + "WHERE _id=appkeys_id"); + + db.execSQL("CREATE VIEW dataItemsAndAssets AS SELECT " + + "appKeyDataItems.packageName AS packageName, " + + "appKeyDataItems.signatureDigest AS signatureDigest, " + + "appKeyDataItems.dataitems_id AS dataitems_id, " + + "appKeyDataItems.host AS host, " + + "appKeyDataItems.path AS path, " + + "appKeyDataItems.seqId AS seqId, " + + "appKeyDataItems.deleted AS deleted, " + + "appKeyDataItems.sourceNode AS sourceNode, " + + "appKeyDataItems.data AS data, " + + "appKeyDataItems.timestampMs AS timestampMs, " + + "appKeyDataItems.assetsPresent AS assetsPresent, " + + "assetrefs.assetname AS assetname, " + + "assetrefs.assets_digest AS assets_digest, " + + "appKeyDataItems.v1SourceNode AS v1SourceNode, " + + "appKeyDataItems.v1SeqId AS v1SeqId " + + "FROM appKeyDataItems " + + "LEFT OUTER JOIN assetrefs ON appKeyDataItems.dataitems_id=assetrefs.dataitems_id"); + + db.execSQL("CREATE VIEW assetsReadyStatus AS SELECT " + + "dataitems_id AS dataitems_id, " + + "COUNT(*) = SUM(dataPresent) AS nowReady, " + + "assetsPresent AS markedReady " + + "FROM assetrefs, dataitems " + + "LEFT OUTER JOIN assets ON assetrefs.assets_digest = assets.digest " + + "WHERE assetrefs.dataitems_id=dataitems._id " + + "GROUP BY dataitems_id;"); + + db.execSQL("CREATE VIEW appKeyArchiveDataItems AS SELECT " + + "appkeys._id AS appkeys_id, " + + "appkeys.packageName AS packageName, " + + "appkeys.signatureDigest AS signatureDigest, " + + "archiveDataItems._id AS archiveDataItems_id, " + + "archiveDataItems.migratingNode AS migratingNode, " + + "archiveDataItems.path AS path, " + + "archiveDataItems.data AS data, " + + "archiveDataItems.timestampMs AS timestampMs, " + + "archiveDataItems.assetsPresent AS assetsPresent " + + "FROM appkeys, archiveDataItems " + + "WHERE appkeys._id = archiveDataItems.appkeys_id"); + + db.execSQL("CREATE VIEW archiveDataItemsAndAssets AS SELECT " + + "appKeyArchiveDataItems.appkeys_id AS appkeys_id, " + + "appKeyArchiveDataItems.packageName AS packageName, " + + "appKeyArchiveDataItems.signatureDigest AS signatureDigest, " + + "appKeyArchiveDataItems.archiveDataItems_id AS archiveDataItems_id, " + + "appKeyArchiveDataItems.migratingNode AS migratingNode, " + + "appKeyArchiveDataItems.path AS path, " + + "appKeyArchiveDataItems.data AS data, " + + "appKeyArchiveDataItems.timestampMs AS timestampMs, " + + "appKeyArchiveDataItems.assetsPresent AS assetsPresent, " + + "archiveAssetRefs.assetname AS assetname, " + + "archiveAssetRefs.assets_digest AS assets_digest " + + "FROM appKeyArchiveDataItems " + + "LEFT OUTER JOIN archiveAssetRefs ON appKeyArchiveDataItems.archiveDataItems_id = archiveAssetRefs.archiveDataItems_id"); + + db.execSQL("CREATE VIEW archiveAssetsReadyStatus AS SELECT " + + "archiveDataItems_id AS archiveDataItems_id, " + + "COUNT(*) = SUM(dataPresent) AS nowReady, " + + "assetsPresent AS markedReady " + + "FROM archiveAssetRefs, archiveDataItems " + + "LEFT OUTER JOIN assets ON archiveAssetRefs.assets_digest = assets.digest " + + "WHERE archiveAssetRefs.archiveDataItems_id = archiveDataItems._id " + + "GROUP BY archiveDataItems_id;"); + + db.execSQL("CREATE UNIQUE INDEX appkeys_NAME_AND_SIG ON appkeys(" + + "packageName, signatureDigest);"); + + db.execSQL("CREATE UNIQUE INDEX assetrefs_ASSET_REFS ON assetrefs(" + + "assets_digest, dataitems_id, assetname);"); + + db.execSQL("CREATE INDEX assetrefs_DATAITEM_ID ON assetrefs(dataitems_id);"); + + db.execSQL("CREATE UNIQUE INDEX archiveAssetRefs_ASSET_REFS ON archiveAssetRefs(" + + "assets_digest, archiveDataItems_id, assetname);"); + + db.execSQL("CREATE INDEX archiveAssetRefs_DATAITEM_ID ON archiveAssetRefs(archiveDataItems_id);"); + db.execSQL("CREATE UNIQUE INDEX assets_DIGEST ON assets(digest);"); - db.execSQL("CREATE UNIQUE INDEX assetsacls_APPKEY_AND_DIGEST ON assetsacls(appkeys_id,assets_digest);"); - db.execSQL("CREATE UNIQUE INDEX dataitems_APPKEY_HOST_AND_PATH ON dataitems(appkeys_id,host,path);"); - db.execSQL("CREATE UNIQUE INDEX dataitems_SOURCENODE_AND_SEQID ON dataitems(sourceNode,seqId);"); - db.execSQL("CREATE UNIQUE INDEX dataitems_SOURCENODE_DELETED_AND_SEQID ON dataitems(sourceNode,deleted,seqId);"); + + db.execSQL("CREATE UNIQUE INDEX assetsacls_APPKEY_AND_DIGEST ON assetsacls(" + + "appkeys_id, assets_digest);"); + + db.execSQL("CREATE UNIQUE INDEX dataitems_APPPKEY_PATH_AND_HOST ON dataitems(" + + "appkeys_id, path, host);"); + + db.execSQL("CREATE UNIQUE INDEX dataitems_SOURCENODE_AND_SEQID ON dataitems(" + + "sourceNode, seqId);"); + db.execSQL("CREATE UNIQUE INDEX dataitems_SOURCENODE_DELETED_AND_SEQID ON dataitems(" + + "sourceNode, deleted, seqId);"); + + db.execSQL("CREATE UNIQUE INDEX archiveDataItems_NODE_APPPKEY_PATH ON archiveDataItems(" + + "migratingNode, appkeys_id, path);"); + + } public synchronized Cursor getDataItemsForDataHolder(String packageName, String signatureDigest) { @@ -70,25 +234,53 @@ public synchronized Cursor getDataItemsForDataHolder(String packageName, String } public synchronized Cursor getDataItemsForDataHolderByHostAndPath(String packageName, String signatureDigest, String host, String path) { + SQLiteDatabase db = getReadableDatabase(); + String[] params; String selection; + if (path == null) { params = new String[]{packageName, signatureDigest}; - selection = "packageName = ? AND signatureDigest = ?"; + selection = "a.packageName = ? AND a.signatureDigest = ?"; } else if (TextUtils.isEmpty(host)) { if (path.endsWith("/")) path = path + "%"; path = path.replace("*", "%"); params = new String[]{packageName, signatureDigest, path}; - selection = "packageName = ? AND signatureDigest = ? AND path LIKE ?"; + selection = "a.packageName = ? AND a.signatureDigest = ? AND d.path LIKE ?"; } else { if (path.endsWith("/")) path = path + "%"; path = path.replace("*", "%"); host = host.replace("*", "%"); params = new String[]{packageName, signatureDigest, host, path}; - selection = "packageName = ? AND signatureDigest = ? AND host = ? AND path LIKE ?"; + + selection = "a.packageName = ? AND a.signatureDigest = ? " + + "AND d.host LIKE ? AND d.path LIKE ?"; } - selection += " AND deleted=0 AND assetsPresent !=0"; - return getReadableDatabase().rawQuery("SELECT host AS host,path AS path,data AS data,\'\' AS tags,assetname AS asset_key,assets_digest AS asset_id FROM dataItemsAndAssets WHERE " + selection, params); + + selection += " AND d.deleted = 0 AND d.assetsPresent != 0"; + + String query = + "SELECT " + + "d._id AS _id, " + + "d.host AS host, " + + "d.path AS path, " + + "d.data AS data, " + + "'' AS tags, " + + "d.seqId AS seqId, " + + "d.timestampMs AS timestampMs, " + + "d.deleted AS deleted, " + + "d.sourceNode AS sourceNode, " + + "d.assetsPresent AS assetsPresent, " + + "a.packageName AS packageName, " + + "a.signatureDigest AS signatureDigest, " + + "ar.assetname AS asset_key, " + + "ar.assets_digest AS asset_id " + + "FROM dataitems d " + + "JOIN appkeys a ON d.appkeys_id = a._id " + + "LEFT JOIN assetrefs ar ON ar.dataitems_id = d._id " + + "WHERE " + selection; + + return db.rawQuery(query, params); } public synchronized Cursor getDataItemsByHostAndPath(String packageName, String signatureDigest, String host, String path) { @@ -99,13 +291,14 @@ public synchronized Cursor getDataItemsByHostAndPath(String packageName, String @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion != VERSION) { - // TODO: Upgrade not supported, cleaning up + // just recreate everything db.execSQL("DROP TABLE IF EXISTS appkeys;"); db.execSQL("DROP TABLE IF EXISTS dataitems;"); db.execSQL("DROP TABLE IF EXISTS assets;"); db.execSQL("DROP TABLE IF EXISTS assetrefs;"); db.execSQL("DROP TABLE IF EXISTS assetsacls;"); db.execSQL("DROP TABLE IF EXISTS nodeinfo;"); + db.execSQL("DROP TABLE IF EXISTS nodeMigration;"); db.execSQL("DROP VIEW IF EXISTS appKeyDataItems;"); db.execSQL("DROP VIEW IF EXISTS appKeyAcls;"); db.execSQL("DROP VIEW IF EXISTS dataItemsAndAssets;"); @@ -116,60 +309,153 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { private static synchronized long getAppKey(SQLiteDatabase db, String packageName, String signatureDigest) { Cursor cursor = db.rawQuery("SELECT _id FROM appkeys WHERE packageName=? AND signatureDigest=?", new String[]{packageName, signatureDigest}); - if (cursor != null) { - try { - if (cursor.moveToNext()) { - return cursor.getLong(0); - } - } finally { - cursor.close(); + + try { + if (cursor.moveToNext()) { + return cursor.getLong(0); } + } finally { + cursor.close(); } + ContentValues appKey = new ContentValues(); appKey.put("packageName", packageName); appKey.put("signatureDigest", signatureDigest); return db.insert("appkeys", null, appKey); } + private static boolean resolveAssetsPresent(DataItemRecord record) { + if (record.assetsAreReady) return true; + Map assets = record.dataItem.getAssets(); + return assets == null || assets.isEmpty(); + } + public synchronized void putRecord(DataItemRecord record) { SQLiteDatabase db = getWritableDatabase(); db.beginTransaction(); - Cursor cursor = getDataItemsByHostAndPath(db, record.packageName, record.signatureDigest, record.dataItem.host, record.dataItem.path); try { - String key; - if (cursor.moveToNext()) { - // update - key = cursor.getString(0); - updateRecord(db, key, record); - } else { - // insert - key = insertRecord(db, record); - } - if (record.assetsAreReady) { - ContentValues update = new ContentValues(); - update.put("assetsPresent", 1); - db.update("dataitems", update, "_id=?", new String[]{key}); + long appKeyId = getAppKey(db, record.packageName, record.signatureDigest); + + ContentValues cv = new ContentValues(); + cv.put("appkeys_id", appKeyId); + cv.put("host", record.dataItem.host); + cv.put("path", record.dataItem.path); + cv.put("seqId", record.seqId); + cv.put("deleted", record.deleted ? 1 : 0); + cv.put("sourceNode", record.source); + cv.put("data", record.dataItem.data); + cv.put("timestampMs", System.currentTimeMillis()); + cv.put("assetsPresent", resolveAssetsPresent(record) ? 1 : 0); + cv.put("v1SourceNode", record.source); + cv.put("v1SeqId", record.v1SeqId != 0 ? record.v1SeqId : record.seqId); + + long dataItemId = db.insertWithOnConflict("dataitems", null, cv, + SQLiteDatabase.CONFLICT_REPLACE); + + db.delete("assetrefs", "dataitems_id=?", + new String[]{String.valueOf(dataItemId)}); + + if (record.dataItem.getAssets() != null && !record.dataItem.getAssets().isEmpty()) { + for (Map.Entry entry : record.dataItem.getAssets().entrySet()) { + ContentValues assetRef = new ContentValues(); + assetRef.put("dataitems_id", dataItemId); + assetRef.put("assetname", entry.getKey()); + assetRef.put("assets_digest", entry.getValue().getDigest()); + + db.insertWithOnConflict("assetrefs", null, assetRef, + SQLiteDatabase.CONFLICT_IGNORE); + } } + db.setTransactionSuccessful(); + } catch (Exception e) { + Log.e(TAG, "Error in putRecord", e); } finally { - cursor.close(); + db.endTransaction(); } - db.endTransaction(); } - private static void updateRecord(SQLiteDatabase db, String key, DataItemRecord record) { - ContentValues cv = record.toContentValues(); - db.update("dataitems", cv, "_id=?", new String[]{key}); - finishRecord(db, key, record); + private static void updateRecord(SQLiteDatabase db, String dataItemId, DataItemRecord record) { + ContentValues cv = new ContentValues(); + cv.put("seqId", record.seqId); + cv.put("deleted", record.deleted ? 1 : 0); + cv.put("sourceNode", record.source); + cv.put("data", record.dataItem.data); + cv.put("timestampMs", System.currentTimeMillis()); + cv.put("assetsPresent", record.assetsAreReady ? 1 : 0); + cv.put("v1SourceNode", record.source); + cv.put("v1SeqId", record.v1SeqId != 0 ? record.v1SeqId : record.seqId); + db.update("dataitems", cv, "_id=?", new String[]{dataItemId}); + db.delete("assetrefs", "dataitems_id=?", new String[]{dataItemId}); + + if (record.dataItem.getAssets() != null && !record.dataItem.getAssets().isEmpty()) { + for (Map.Entry entry : record.dataItem.getAssets().entrySet()) { + ContentValues assetRef = new ContentValues(); + assetRef.put("dataitems_id", Long.parseLong(dataItemId)); + assetRef.put("assetname", entry.getKey()); + assetRef.put("assets_digest", entry.getValue().getDigest()); + + db.insertWithOnConflict("assetrefs", null, assetRef, + SQLiteDatabase.CONFLICT_IGNORE); + } + } + + } + + public Map getAllCurrentSeqIds() { + Map res = new HashMap<>(); + SQLiteDatabase db = getReadableDatabase(); + Cursor cur = db.rawQuery( + "SELECT sourceNode, MAX(seqId) FROM dataitems GROUP BY sourceNode", + null); + + try { + while (cur.moveToNext()) { + String node = cur.getString(0); + long seqId = cur.getLong(1); + if (node != null) res.put(node, seqId); + } + } finally { + cur.close(); + } + return res; } private static String insertRecord(SQLiteDatabase db, DataItemRecord record) { - ContentValues contentValues = record.toContentValues(); - contentValues.put("appkeys_id", getAppKey(db, record.packageName, record.signatureDigest)); - contentValues.put("host", record.dataItem.host); - contentValues.put("path", record.dataItem.path); - String key = Long.toString(db.insertWithOnConflict("dataitems", "host", contentValues, SQLiteDatabase.CONFLICT_REPLACE)); - return finishRecord(db, key, record); + long appKeyId = getAppKey(db, record.packageName, record.signatureDigest); + + ContentValues cv = new ContentValues(); + cv.put("appkeys_id", appKeyId); + cv.put("host", record.dataItem.host); + cv.put("path", record.dataItem.path); + cv.put("seqId", record.seqId); + cv.put("deleted", record.deleted ? 1 : 0); + cv.put("sourceNode", record.source); + cv.put("data", record.dataItem.data); + cv.put("timestampMs", System.currentTimeMillis()); + cv.put("assetsPresent", resolveAssetsPresent(record) ? 1 : 0); + cv.put("v1SourceNode", record.source); + cv.put("v1SeqId", record.v1SeqId != 0 ? record.v1SeqId : record.seqId); + + long dataItemId = db.insertWithOnConflict("dataitems", null, cv, + SQLiteDatabase.CONFLICT_REPLACE); + + db.delete("assetrefs", "dataitems_id=?", + new String[]{String.valueOf(dataItemId)}); + + if (record.dataItem.getAssets() != null && !record.dataItem.getAssets().isEmpty()) { + for (Map.Entry entry : record.dataItem.getAssets().entrySet()) { + ContentValues assetRef = new ContentValues(); + assetRef.put("dataitems_id", dataItemId); + assetRef.put("assetname", entry.getKey()); + assetRef.put("assets_digest", entry.getValue().getDigest()); + + db.insertWithOnConflict("assetrefs", null, assetRef, + SQLiteDatabase.CONFLICT_IGNORE); + } + } + + return String.valueOf(dataItemId); } private static String finishRecord(SQLiteDatabase db, String key, DataItemRecord record) { @@ -181,7 +467,10 @@ private static String finishRecord(SQLiteDatabase db, String key, DataItemRecord assetValues.put("assetname", asset.getKey()); db.insertWithOnConflict("assetrefs", "assetname", assetValues, SQLiteDatabase.CONFLICT_IGNORE); } - Cursor status = db.query("assetsReadyStatus", new String[]{"nowReady"}, "dataitems_id=?", new String[]{key}, null, null, null); + Cursor status = db.query("assetsReadyStatus", + new String[]{"nowReady"}, "dataitems_id=?", + new String[]{key}, null, null, null); + if (status.moveToNext()) { record.assetsAreReady = status.getLong(0) != 0; } @@ -192,26 +481,72 @@ private static String finishRecord(SQLiteDatabase db, String key, DataItemRecord return key; } - private static Cursor getDataItemsByHostAndPath(SQLiteDatabase db, String packageName, String signatureDigest, String host, String path) { + private static Cursor getDataItemsByHostAndPath(SQLiteDatabase db, String packageName, + String signatureDigest, String host, String path) { String[] params; String selection; + if (path == null) { params = new String[]{packageName, signatureDigest}; - selection = "packageName =? AND signatureDigest =?"; + selection = "packageName = ? AND signatureDigest = ? AND deleted = 0"; } else if (host == null) { - params = new String[]{packageName, signatureDigest, path}; - selection = "packageName =? AND signatureDigest =? AND path =?"; + String pathPattern = path; + if (path.endsWith("/")) { + pathPattern = path + "*"; + } + pathPattern = pathPattern.replace("*", "%"); + + params = new String[]{packageName, signatureDigest, pathPattern}; + selection = "packageName = ? AND signatureDigest = ? AND path LIKE ? AND deleted = 0"; } else { - params = new String[]{packageName, signatureDigest, host, path}; - selection = "packageName =? AND signatureDigest =? AND host =? AND path =?"; + String pathPattern = path; + if (path.endsWith("/")) { + pathPattern = path + "*"; + } + pathPattern = pathPattern.replace("*", "%"); + + String hostPattern = host.replace("*", "%"); + + params = new String[]{packageName, signatureDigest, hostPattern, pathPattern}; + selection = "packageName = ? AND signatureDigest = ? AND host LIKE ? AND path LIKE ? AND deleted = 0"; } - selection += " AND deleted=0"; - return db.query("dataItemsAndAssets", GDIBHAP_FIELDS, selection, params, null, null, "packageName, signatureDigest, host, path"); + + return db.query("dataItemsAndAssets", GDIBHAP_FIELDS, selection, params, + null, null, "packageName, signatureDigest, host, path"); } public Cursor getModifiedDataItems(final String nodeId, final long seqId, final boolean excludeDeleted) { - String selection = "sourceNode =? AND seqId >?" + (excludeDeleted ? " AND deleted =0" : ""); - return getReadableDatabase().query("dataItemsAndAssets", GDIBHAP_FIELDS, selection, new String[]{nodeId, Long.toString(seqId)}, null, null, "seqId", null); + SQLiteDatabase db = getReadableDatabase(); + + String selection = "d.sourceNode = ? AND d.seqId > ?"; + if (excludeDeleted) { + selection += " AND d.deleted = 0"; + } + + String query = + "SELECT " + + "d._id AS dataitems_id, " + + "a.packageName AS packageName, " + + "a.signatureDigest AS signatureDigest, " + + "d.host AS host, " + + "d.path AS path, " + + "d.seqId AS seqId, " + + "d.deleted AS deleted, " + + "d.sourceNode AS sourceNode, " + + "d.data AS data, " + + "d.timestampMs AS timestampMs, " + + "d.assetsPresent AS assetsPresent, " + + "ar.assetname AS assetname, " + + "ar.assets_digest AS assets_digest, " + + "d.v1SourceNode AS v1SourceNode, " + + "d.v1SeqId AS v1SeqId " + + "FROM dataitems d " + + "JOIN appkeys a ON d.appkeys_id = a._id " + + "LEFT JOIN assetrefs ar ON ar.dataitems_id = d._id " + + "WHERE " + selection + " " + + "ORDER BY d.seqId"; + + return db.rawQuery(query, new String[]{nodeId, Long.toString(seqId)}); } public synchronized List deleteDataItems(String packageName, String signatureDigest, String host, String path) { @@ -234,6 +569,18 @@ public synchronized List deleteDataItems(String packageName, Str return updated; } + public synchronized void deleteAllItemsBySourceNode(String sourceNodeId) { + if (TextUtils.isEmpty(sourceNodeId)) return; + SQLiteDatabase db = getWritableDatabase(); + db.execSQL( + "DELETE FROM assetrefs WHERE dataitems_id IN " + + "(SELECT _id FROM dataitems WHERE sourceNode=?)", + new String[]{sourceNodeId} + ); + int rows = db.delete("dataitems", "sourceNode=?", new String[]{sourceNodeId}); + Log.d(TAG, "deleteAllItemsBySourceNode: removed " + rows + " items for node=" + sourceNodeId); + } + public long getCurrentSeqId(String sourceNode) { if (TextUtils.isEmpty(sourceNode)) return 1; return getCurrentSeqId(getReadableDatabase(), sourceNode); @@ -242,12 +589,10 @@ public long getCurrentSeqId(String sourceNode) { private long getCurrentSeqId(SQLiteDatabase db, String sourceNode) { Cursor cursor = db.query("dataItemsAndAssets", new String[]{"seqId"}, "sourceNode=?", new String[]{sourceNode}, null, null, "seqId DESC", "1"); long res = 1; - if (cursor != null) { - if (cursor.moveToFirst()) { - res = cursor.getLong(0); - } - cursor.close(); + if (cursor.moveToFirst()) { + res = cursor.getLong(0); } + cursor.close(); return res; } @@ -268,12 +613,40 @@ public synchronized void allowAssetAccess(String digest, String packageName, Str } public Cursor listMissingAssets() { - return getReadableDatabase().query("dataItemsAndAssets", GDIBHAP_FIELDS, "assetsPresent = 0 AND assets_digest NOT NULL", null, null, null, "packageName, signatureDigest, host, path"); + SQLiteDatabase db = getReadableDatabase(); + + String query = + "SELECT DISTINCT " + + "a.packageName AS packageName, " + + "a.signatureDigest AS signatureDigest, " + + "d.host AS host, " + + "d.path AS path, " + + "d.seqId AS seqId, " + + "d.deleted AS deleted, " + + "d.sourceNode AS sourceNode, " + + "d.data AS data, " + + "d.timestampMs AS timestampMs, " + + "d.assetsPresent AS assetsPresent, " + + "d.v1SourceNode AS v1SourceNode, " + + "d.v1SeqId AS v1SeqId, " + + "ar.assetname AS assetname, " + + "ar.assets_digest AS assets_digest " + + "FROM dataitems d " + + "JOIN appkeys a ON d.appkeys_id = a._id " + + "JOIN assetrefs ar ON d._id = ar.dataitems_id " + + "LEFT JOIN assets ast ON ar.assets_digest = ast.digest " + + "WHERE d.deleted = 0 " + + "AND (ast.dataPresent = 0 OR ast.dataPresent IS NULL) " + + "ORDER BY a.packageName, a.signatureDigest, d.host, d.path"; + + + return db.rawQuery(query, null); } public boolean hasAsset(Asset asset) { - Cursor cursor = getReadableDatabase().query("assets", new String[]{"dataPresent"}, "digest=?", new String[]{asset.getDigest()}, null, null, null); - if (cursor == null) return false; + Cursor cursor = getReadableDatabase().query("assets",new String[]{"dataPresent"}, + "digest=?", new String[]{asset.getDigest()}, + null, null, null); try { return (cursor.moveToNext() && cursor.getInt(0) == 1); } finally { @@ -281,17 +654,83 @@ public boolean hasAsset(Asset asset) { } } + public void markAssetAsMissing(String digest, String packageName, String signatureDigest) { + SQLiteDatabase db = getWritableDatabase(); + + Cursor cursor = db.query("assets", new String[]{"digest"}, + "digest = ?", new String[]{digest}, null, null, null); + + boolean exists = cursor.moveToFirst(); + cursor.close(); + + if (!exists) { + ContentValues values = new ContentValues(); + values.put("digest", digest); + values.put("dataPresent", 0); + values.put("timestampMs", System.currentTimeMillis()); + db.insertWithOnConflict("assets", null, values, SQLiteDatabase.CONFLICT_IGNORE); + } + + allowAssetAccess(digest, packageName, signatureDigest); + } + + public Cursor getDataItemsWaitingForAsset(String digest) { + SQLiteDatabase db = getReadableDatabase(); + + String query = "SELECT " + + "d._id AS dataitems_id, " + + "a.packageName AS packageName, " + + "a.signatureDigest AS signatureDigest, " + + "d.host AS host, " + + "d.path AS path, " + + "d.seqId AS seqId, " + + "d.deleted AS deleted, " + + "d.sourceNode AS sourceNode, " + + "d.data AS data, " + + "d.timestampMs AS timestampMs, " + + "d.assetsPresent AS assetsPresent, " + + "ar.assetname AS assetname, " + + "ar.assets_digest AS assets_digest, " + + "d.v1SourceNode AS v1SourceNode, " + + "d.v1SeqId AS v1SeqId " + + "FROM dataitems d " + + "JOIN appkeys a ON d.appkeys_id = a._id " + + "JOIN assetrefs ar ON d._id = ar.dataitems_id " + + "WHERE d.assetsPresent = 0 " + + "AND ar.assets_digest = ?"; + + return db.rawQuery(query, new String[]{digest}); + } + + public synchronized void updateAssetsReady(String uri, boolean ready) { + ContentValues cv = new ContentValues(); + cv.put("assetsPresent", ready ? 1 : 0); + + getWritableDatabase().update("dataitems", cv, + "host || path = ?", new String[]{uri}); + } + public synchronized void markAssetAsPresent(String digest) { ContentValues cv = new ContentValues(); + cv.put("digest", digest); cv.put("dataPresent", 1); - SQLiteDatabase db = getWritableDatabase(); - db.update("assets", cv, "digest=?", new String[]{digest}); - Cursor status = db.query("assetsReadyStatus", null, "nowReady != markedReady", null, null, null, null); - while (status.moveToNext()) { - cv = new ContentValues(); - cv.put("assetsPresent", status.getInt(status.getColumnIndexOrThrow("nowReady"))); - db.update("dataitems", cv, "_id=?", new String[]{Integer.toString(status.getInt(status.getColumnIndexOrThrow("dataitems_id")))}); - } - status.close(); + cv.put("timestampMs", System.currentTimeMillis()); + + getWritableDatabase().insertWithOnConflict("assets", null, cv, + SQLiteDatabase.CONFLICT_REPLACE); } + + public synchronized Cursor getDataItemsByCapabilityName(String capabilityName) { + String pathSuffix = "%/capabilities/%/" + capabilityName; + return getReadableDatabase().query( + "dataItemsAndAssets", + GDIBHAP_FIELDS, + "path LIKE ? AND deleted = 0", + new String[]{pathSuffix}, + null, null, + "host, path" + ); + } + + } diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeMigrationController.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeMigrationController.java new file mode 100644 index 0000000000..de2a939743 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeMigrationController.java @@ -0,0 +1,220 @@ +package org.microg.gms.wearable; + +import android.os.Build; +import android.util.Log; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class NodeMigrationController { + private static final String TAG = "NodeMigrationController"; + + public static final int CTRL_UNKNOWN = 1; + public static final int CTRL_TERMINATE_ASSOCIATION = 2; + public static final int CTRL_SUSPEND_SYNC = 3; + public static final int CTRL_RESUME_SYNC = 4; + public static final int CTRL_MIGRATION_FAILED = 5; + public static final int CTRL_ACCOUNT_MATCHING = 6; + public static final int CTRL_MIGRATION_CANCELLED = 7; + + public final ReentrantReadWriteLock migrationLock = new ReentrantReadWriteLock(); + public final ReentrantReadWriteLock archiveLock = new ReentrantReadWriteLock(); + + public final ConcurrentHashMap> nodeToCompletedAppsMap = new ConcurrentHashMap<>(); + public final Object denylistLock = new Object(); + public final ConcurrentHashMap> nodeToDenylistMap = new ConcurrentHashMap<>(); + public final Map archiveNodeHighwaterMap = new ConcurrentHashMap<>(); + public final Set completedNodes; + + public final AtomicBoolean migrationActive = new AtomicBoolean(false); + + private final AtomicInteger cancellationState = new AtomicInteger(0); + + private final AtomicReference migratingTo = new AtomicReference<>(null); + private final AtomicReference migratingFrom = new AtomicReference<>(null); + + private final Set suspendedNodes; + + public NodeMigrationController() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + this.completedNodes = ConcurrentHashMap.newKeySet(); + this.suspendedNodes = ConcurrentHashMap.newKeySet(); + } else { + this.completedNodes = Collections.newSetFromMap(new ConcurrentHashMap<>()); + this.suspendedNodes = Collections.newSetFromMap(new ConcurrentHashMap<>()); + } + } + + public void startMigrationForNode(String nodeId) { + Log.d(TAG, "Starting migration for node " + nodeId); + Set slot = Collections.newSetFromMap(new ConcurrentHashMap<>()); + migrationLock.writeLock().lock(); + try { + Set existing = nodeToCompletedAppsMap.get(nodeId); + if (existing == null) { + nodeToCompletedAppsMap.put(nodeId, slot); + } + if (existing != null) { + Log.d(TAG, "Node " + nodeId + " already migrating with completed apps: " + existing); + } + } finally { + migrationLock.writeLock().unlock(); + } + } + + public void markNodeMigrationCompleted(String nodeId) { + migrationLock.writeLock().lock(); + try { + Set completedApps = nodeToCompletedAppsMap.remove(nodeId); + if (completedApps != null) { + Log.d(TAG, "Marking " + nodeId + " as completed with apps: " + completedApps); + } + } finally { + migrationLock.writeLock().unlock(); + } + + synchronized (denylistLock) { + nodeToDenylistMap.remove(nodeId); + } + } + + public void markAppMigrationComplete(String nodeId, String packageName) { + migrationLock.writeLock().lock(); + try { + Set completedApps = nodeToCompletedAppsMap.get(nodeId); + if (completedApps != null) { + completedApps.add(packageName); + Log.d(TAG, "App " + packageName + " migration complete for node " + nodeId); + } + } finally { + migrationLock.writeLock().unlock(); + } + } + + public void addCompletedNode(String nodeId) { + completedNodes.add(nodeId); + } + + public boolean shouldDeliverEvents(String packageName, String sourceNodeId) { + synchronized (denylistLock) { + Set denylist = nodeToDenylistMap.get(sourceNodeId); + if (denylist != null) { + return !denylist.contains(packageName); + } + } + + migrationLock.readLock().lock(); + try { + Set completedApps = nodeToCompletedAppsMap.get(sourceNodeId); + return completedApps == null || completedApps.contains(packageName); + } finally { + migrationLock.readLock().unlock(); + } + } + + public boolean isMigrating(String nodeId) { + migrationLock.readLock().lock(); + try { + return nodeToCompletedAppsMap.containsKey(nodeId); + } finally { + migrationLock.readLock().unlock(); + } + } + + public boolean startPhoneSwitchMigration(String newNodeId, String oldNodeId) { + if (!migrationActive.compareAndSet(false, true)) { + Log.e(TAG, "startPhoneSwitchMigration: already migrating to " + migratingTo.get()); + return false; + } + + migratingTo.set(newNodeId); + migratingFrom.set(oldNodeId); + cancellationState.set(1); + Log.i(TAG, "startPhoneSwitchMigration: to="+ newNodeId + " from=" + oldNodeId); + return true; + } + + public void setMigratingFromNodeId(String oldNodeId) { + migratingFrom.set(oldNodeId); + Log.d(TAG, "setMigratingFromNodeId: " + oldNodeId); + } + + public void onMigrationSucceeded() { + String from = migratingFrom.getAndSet(null); + String to = migratingTo.getAndSet(null); + migrationActive.set(false); + cancellationState.set(0); + if (from != null) { + suspendedNodes.remove(from); + } + Log.i(TAG, "onMigrationSucceeded: to=" + to + " from=" + from); + } + + public void onMigrationAborted() { + String from = migratingFrom.getAndSet(null); + String to = migratingTo.getAndSet(null); + migrationActive.set(false); + cancellationState.set(0); + if (from != null) { + suspendedNodes.remove(from); + } + if (to != null) { + nodeToCompletedAppsMap.remove(to); + } + Log.i(TAG, "onMigrationAborted: to=" + to + " from=" + from); + } + + public boolean isMigrationActive() { + return migrationActive.get(); + } + + public String getMigratingToNodeId() { + return migratingTo.get(); + } + + public String getMigratingFromNodeId() { + return migratingFrom.get(); + } + + public boolean markCancelled() { + boolean ok = cancellationState.compareAndSet(1, 3); + if (ok) { + Log.d(TAG, "markCancelled: Migration marked cancelled"); + } else { + Log.w(TAG, "markCancelled: could not cancel, state=" + cancellationState.get()); + } + return ok; + } + + public boolean isCancelled() { + return cancellationState.get() == 3; + } + + public void resetCancellable() { + if (cancellationState.compareAndSet(3, 1)) { + Log.d(TAG, "resetCancellable: reset to cancellable"); + } + } + + public void suspendNode(String nodeId) { + if (suspendedNodes.add(nodeId)) { + Log.d(TAG, "suspendNode: " + nodeId); + } + } + + public void resumeNode(String nodeId) { + if (suspendedNodes.remove(nodeId)) { + Log.d(TAG, "resumeNode: " + nodeId); + } + } + + public boolean isNodeSuspended(String nodeId) { + return suspendedNodes.contains(nodeId); + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeMigrationTracker.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeMigrationTracker.java new file mode 100644 index 0000000000..14816e8f9b --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeMigrationTracker.java @@ -0,0 +1,97 @@ +package org.microg.gms.wearable; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.util.Log; + +import java.util.HashMap; +import java.util.Map; + +public class NodeMigrationTracker { + private static final String TAG = "NodeMigrationTracker"; + + static final String TABLE = "nodeMigration"; + static final String COL_NODE_ID = "nodeId"; + static final String COL_MIGRATING_FROM = "migratingFromNodeId"; + static final String COL_COMPLETE = "complete"; + + private final Map migrationMap = new HashMap<>(); + + private final NodeDatabaseHelper db; + + public NodeMigrationTracker(NodeDatabaseHelper db) { + this.db = db; + } + + public void updateMigrationInfo(SQLiteDatabase writable, String newNodeId, String migratingFromNodeId) { + Log.i(TAG, "setNodeMigratingFrom(" + newNodeId + ", " + migratingFromNodeId + ")"); + ContentValues cv = new ContentValues(); + cv.put(COL_NODE_ID, newNodeId); + cv.put(COL_MIGRATING_FROM, migratingFromNodeId); + cv.put(COL_COMPLETE, 0); + writable.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE); + + if (!migrationMap.containsKey(newNodeId)) { + migrationMap.put(newNodeId, migratingFromNodeId); + } + } + + public void clearMigrationInfo(String nodeId) { + Log.i(TAG, "clearMigrationInfo: " + nodeId); + try { + db.getWritableDatabase().delete(TABLE, COL_NODE_ID + "=?", new String[]{nodeId}); + } catch (SQLiteException e) { + Log.w(TAG, "clearMigrationInfo: DB error for" + nodeId, e); + } + migrationMap.remove(nodeId); + } + + public String getMigratingFromNodeId(String nodeId) { + String cached = migrationMap.get(nodeId); + if (cached != null) { + return cached; + } + + Cursor c = db.getReadableDatabase().query(TABLE, + new String[]{COL_MIGRATING_FROM}, + COL_NODE_ID + "=?", new String[]{nodeId}, + null, null, null); + try { + if (c.moveToFirst()) return c.getString(0); + } finally { + c.close(); + } + return null; + } + + public static boolean isNodeMigrationComplete(SQLiteDatabase db, String nodeId) { + Cursor c = db.query(TABLE, + new String[]{COL_COMPLETE}, + COL_NODE_ID + "=?", new String[]{nodeId}, + null, null, null); + try { + return c.moveToFirst() && c.getInt(0) == 1; + } finally { + c.close(); + } + } + + public void setMigrationComplete(String nodeId) { + Log.i(TAG, "setMigrationComplete(" + nodeId + ")"); + ContentValues cv = new ContentValues(); + cv.put(COL_COMPLETE, 1); + try { + db.getWritableDatabase().update(TABLE, cv, + COL_NODE_ID + "=?", new String[]{nodeId}); + } catch (SQLiteException e) { + Log.w(TAG, "setMigrationComplete: DB error for " + nodeId, e); + } + migrationMap.remove(nodeId); + } + + public boolean isMigrating(String nodeId) { + return migrationMap.containsKey(nodeId); + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/RpcHelper.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/RpcHelper.java index b6a638cdf4..8859df2e9e 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/RpcHelper.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/RpcHelper.java @@ -18,20 +18,39 @@ import android.content.Context; import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.Nullable; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class RpcHelper { private final Map rpcStateMap = new HashMap(); private final SharedPreferences preferences; private final Context context; + private final Map rpcListeners = new ConcurrentHashMap<>(); + private final Map dataSyncListeners = new ConcurrentHashMap<>(); + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + public RpcHelper(Context context) { this.context = context; this.preferences = context.getSharedPreferences("wearable.rpc_service.settings", 0); } + public static int combineId(int generation, int requestId) { + // magic numbers, need to find out what exactly is 527 and 31 + return ((generation + 527) * 31) + requestId; + } + + private static String rpcListenerKey(String peerNodeId, int requestId) { + return peerNodeId + ":" + requestId; + } + private String getRpcConnectionId(String packageName, String targetNodeId, String path) { String mode = "lo"; if (packageName.equals("com.google.android.wearable.app") && path.startsWith("/s3")) @@ -67,4 +86,89 @@ public RpcConnectionState freeze() { return res; } } + + public void addResponseListener(String peerNodeId, int requestId, long timeoutMs, + RpcResponseCallback onResponse, + RpcTimeoutCallback onTimeout) { + String key = rpcListenerKey(peerNodeId, requestId); + rpcListeners.put(key, new PendingRpcListener(requestId, onResponse, onTimeout)); + mainHandler.postDelayed(() -> { + if (rpcListeners.remove(key) != null) { + onTimeout.onTimeout(); + } + }, timeoutMs); + } + + public boolean deliverRpcResponse(String peerNodeId, int senderRequestId, @Nullable byte[] data) { + PendingRpcListener listener = rpcListeners.remove(rpcListenerKey(peerNodeId, senderRequestId)); + if (listener == null) return false; + listener.callback.onResponse(data); + return true; + } + + + public interface RpcResponseCallback { + void onResponse(@Nullable byte[] data); + } + + public interface RpcTimeoutCallback { + void onTimeout(); + } + + private static final class PendingRpcListener { + final int reqId; + final RpcResponseCallback callback; + final RpcTimeoutCallback timeout; + + PendingRpcListener(int reqId, RpcResponseCallback rc, RpcTimeoutCallback tc) { + this.reqId = reqId; + this.callback = rc; + this.timeout = tc; + } + } + + public void addDataSyncListener(String nodeId, String trackerId, + long timeoutMs, DataSyncResponseCallback onReached, + DataSyncTimeoutCallback onTimeout) { + String key = nodeId + ":" + trackerId; + dataSyncListeners.put(key, new PendingDataSyncListener(onReached, onTimeout)); + mainHandler.postDelayed(() -> { + if (dataSyncListeners.remove(key) != null) { + onTimeout.onTimeout(); + } + }, timeoutMs); + } + + public void cancelDataSyncListener(String nodeId, String trackerId) { + dataSyncListeners.remove(nodeId + ":" + trackerId); + } + + public boolean deliverDataSyncResponse(String peerNodeId, String trackerId, + long reachedSeqId) { + String key = peerNodeId + ":" + trackerId; + PendingDataSyncListener listener = dataSyncListeners.remove(key); + if (listener == null) return false; + listener.callback.onReached(reachedSeqId); + return true; + } + + public interface DataSyncResponseCallback { + void onReached(long reachedSeqId); + } + + public interface DataSyncTimeoutCallback { + void onTimeout(); + } + + private static final class PendingDataSyncListener { + final DataSyncResponseCallback callback; + final DataSyncTimeoutCallback timeout; + + PendingDataSyncListener(DataSyncResponseCallback cb, DataSyncTimeoutCallback to) { + this.callback = cb; + this.timeout = to; + } + } + + } diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/RpcMessageTransport.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/RpcMessageTransport.java new file mode 100644 index 0000000000..f0672e212e --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/RpcMessageTransport.java @@ -0,0 +1,187 @@ +package org.microg.gms.wearable; + +import android.text.TextUtils; +import android.util.Log; + +import com.google.android.gms.wearable.MessageOptions; + +import org.microg.gms.wearable.proto.ChannelRequest; +import org.microg.gms.wearable.proto.Request; +import org.microg.gms.wearable.proto.RootMessage; + +import java.util.UUID; + +import okio.ByteString; + +public class RpcMessageTransport { + private static final String TAG = "RpcTransport"; + + public static final int PROTO_PRIO_UNSPECIFIED = 0; + public static final int PROTO_PRIO_NORMAL = 1; + public static final int PROTO_PRIO_LOW = 2; + + public static int API_PRIO_LOW = 1; + + private static final String WEARABLE_APP_PACKAGE = "com.google.android.wearable.app"; + + public interface Host { + String getLocalNodeId(); + + WearableWriter getWriter(String nodeId); + + void onWriteFailed(String nodeId); + } + + private final RpcHelper rpcHelper; + private Host host; + + public RpcMessageTransport(RpcHelper rpcHelper, Host host) { + this.host = host; + this.rpcHelper = rpcHelper; + } + + public static int toProtoPriority(MessageOptions options) { + return options != null && options.priority == API_PRIO_LOW + ? PROTO_PRIO_LOW : PROTO_PRIO_NORMAL; + } + + public static MessageOptions toMessageOptions(Request request) { + int p = request.priority != null ? request.priority : PROTO_PRIO_UNSPECIFIED; + return new MessageOptions(p == PROTO_PRIO_LOW ? API_PRIO_LOW : 0); + } + public static int writerQueuePriority(Request request) { + if (isS3ProxyPath(request.packageName, request.path)) return 8; + if (toMessageOptions(request).priority == API_PRIO_LOW) return 10; + if (request.request != null) return 9; + return 4; + } + + public static boolean isS3ProxyPath(String packageName, String path) { + return WEARABLE_APP_PACKAGE.equals(packageName) && path != null && path.startsWith("/s3"); + } + + public Request buildRequest(String packageName, String signatureDigest, String targetNodeId, + String path, byte[] data, ChannelRequest channel, + boolean requiresResponse, Integer senderRequestId, + MessageOptions options) { + if (data != null && channel != null) { + throw new IllegalArgumentException("can't set data and channel"); + } + RpcHelper.RpcConnectionState state = + rpcHelper.useConnectionState(packageName, targetNodeId, path); + + Request.Builder b = new Request.Builder() + .requestId(state.lastRequestId) + .generation(state.generation) + .packageName(packageName) + .signatureDigest(signatureDigest) + .targetNodeId(targetNodeId) + .sourceNodeId(host.getLocalNodeId()) + .path(path) + .unknown5(0) + .priority(toProtoPriority(options)); + + if (requiresResponse) b.requiresResponse(true); + if (senderRequestId != null) b.senderRequestId(senderRequestId); + + if (channel != null) { + b.request(channel); + } else { + b.rawData(data != null ? ByteString.of(data) : ByteString.EMPTY); + } + return b.build(); + } + + public static RootMessage wrap(Request request) { + RootMessage.Builder b = new RootMessage.Builder(); + if (Boolean.TRUE.equals(request.requiresResponse) || request.senderRequestId != null) { + b.rpcServiceRequest(request); + } else if (request.request != null) { + b.channelRequest(request); + } else { + b.rpcRequest(request); + } + return b.build(); + } + + public int send(String nodeId, Request request) { + if (TextUtils.isEmpty(nodeId)) { + Log.w(TAG, "send: no target node for " + request.path); + return -1; + } + if (nodeId.equals(host.getLocalNodeId())) { + Log.w(TAG, "send: target is the local node, refusing " + request.path); + return -1; + } + WearableWriter writer = host.getWriter(nodeId); + if (writer == null || !writer.enqueue(wrap(request))) { + Log.w(TAG, "send: no writer available for " + nodeId + " path=" + request.path); + host.onWriteFailed(nodeId); + return -1; + } + return idOf(request); + } + + public int sendMessage(String packageName, String signatureDigest, String nodeId, + String path, byte[] data, MessageOptions options) { + return send(nodeId, buildRequest(packageName, signatureDigest, nodeId, path, data, + null, false, null, options)); + } + + public int sendRequest(String packageName, String signatureDigest, String nodeId, + String path, byte[] data, MessageOptions options) { + return send(nodeId, buildRequest(packageName, signatureDigest, nodeId, path, data, + null, true, null, options)); + } + + public int sendResponse(String packageName, String signatureDigest, String peerNodeId, + String path, byte[] data, int senderRequestId) { + return send(peerNodeId, buildRequest(packageName, signatureDigest, peerNodeId, path, + data, null, false, senderRequestId, null)); + } + + private static int idOf(Request r) { + return RpcHelper.combineId(r.generation != null ? r.generation : 0, + r.requestId != null ? r.requestId : 0); + } + + public static Request extractRpc(RootMessage message) { + if (message.rpcRequest != null) return message.rpcRequest; + if (message.channelRequest != null) return message.channelRequest; + if (message.rpcServiceRequest != null) return message.rpcServiceRequest; + return null; + } + + public Request normalizeInbound(String lastHopNodeId, Request request) { + Request.Builder b = null; + if (TextUtils.isEmpty(request.sourceNodeId)) { + b = request.newBuilder().sourceNodeId(lastHopNodeId); + } + if (isNodeIdMadeUp(request.targetNodeId)) { + if (b == null) b = request.newBuilder(); + b.targetNodeId(host.getLocalNodeId()); + } + return b == null ? request : b.build(); + } + + public boolean isForLocalNode(Request request) { + return TextUtils.isEmpty(request.targetNodeId) + || request.targetNodeId.equals(host.getLocalNodeId()); + } + + public static boolean isNodeIdMadeUp(String nodeId) { + if (TextUtils.isEmpty(nodeId)) return true; + try { + UUID.fromString(nodeId); + return false; + } catch (IllegalArgumentException ignored) { + try { + Long.parseLong(nodeId, 16); + return false; + } catch (NumberFormatException ignored2) { + Log.v(TAG, "isNodeIdMadeUp: " + nodeId + " is neither uuid nor hex, assuming made up"); + return true; + } + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ServerMessageListener.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ServerMessageListener.java new file mode 100644 index 0000000000..8f46148ccf --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ServerMessageListener.java @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2015, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; + +public abstract class ServerMessageListener extends MessageListener { + private Connect localConnect; + private Connect remoteConnect; + + private boolean handshakeAlreadyDone = false; + + public ServerMessageListener(Connect localConnect) { + this.localConnect = localConnect; + } + + public void markHandshakeAlreadyDone() { + this.handshakeAlreadyDone = true; + } + + @Override + public void onConnected(WearableConnection connection) { + super.onConnected(connection); + if (!handshakeAlreadyDone) { + try { + connection.writeMessage(new RootMessage.Builder().connect(localConnect).build()); + } catch (IOException ignored) {} + } + } + + @Override + public void onDisconnected() { + super.onDisconnected(); + remoteConnect = null; + } + + @Override + public void onConnect(Connect connect) { + this.remoteConnect = connect; + } + + public Connect getRemoteConnect() { + return remoteConnect; + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/SocketConnectionThread.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/SocketConnectionThread.java new file mode 100644 index 0000000000..1de1f2f6e2 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/SocketConnectionThread.java @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2015, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public abstract class SocketConnectionThread extends Thread { + + private SocketWearableConnection wearableConnection; + + private SocketConnectionThread() { + super(); + } + + protected void setWearableConnection(org.microg.gms.wearable.SocketWearableConnection wearableConnection) { + this.wearableConnection = wearableConnection; + } + + public SocketWearableConnection getWearableConnection() { + return wearableConnection; + } + + public abstract void close(); + + public static SocketConnectionThread serverListen(final int port, final WearableConnection.Listener listener) { + return new SocketConnectionThread() { + private ServerSocket serverSocket = null; + private final ExecutorService executor = Executors.newCachedThreadPool(); + + @Override + public void close() { + executor.shutdownNow(); + if (serverSocket != null) { + try { serverSocket.close(); } catch (IOException ignored) {} + serverSocket = null; + } + } + + @Override + public void run() { + try { + serverSocket = new ServerSocket(port); + Socket socket; + while ((socket = serverSocket.accept()) != null && !Thread.interrupted()) { + final Socket accepted = socket; + executor.submit(() -> { + try { + SocketWearableConnection connection = + new SocketWearableConnection(accepted, listener); + setWearableConnection(connection); + connection.run(); + } catch (IOException e) { + // connection failed + } + }); + } + } catch (IOException e) { + // quit + } finally { + try { + if (serverSocket != null) serverSocket.close(); + } catch (IOException ignored) {} + } + } + }; + } + + public static SocketConnectionThread clientConnect(final int port, final WearableConnection.Listener listener) { + return new SocketConnectionThread() { + private Socket socket; + + @Override + public void close() { + if (socket != null) { + try { + socket.close(); + } catch (IOException ignored) { + } + socket = null; + } + } + + @Override + public void run() { + try { + socket = new Socket("127.0.0.1", port); + SocketWearableConnection connection = new SocketWearableConnection(socket, listener); + setWearableConnection(connection); + connection.run(); + } catch (IOException e) { + // quit + } finally { + try { + if (socket != null) socket.close(); + } catch (IOException e) { + } + } + } + }; + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/SocketWearableConnection.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/SocketWearableConnection.java new file mode 100644 index 0000000000..ab085f913d --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/SocketWearableConnection.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2015, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import org.microg.gms.wearable.proto.MessagePiece; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.Socket; + +public class SocketWearableConnection extends WearableConnection { + private final int MAX_PIECE_SIZE = DEFAULT_MAX_PIECE_SIZE + 4096; + private final Socket socket; + private final DataInputStream is; + private final DataOutputStream os; + + public SocketWearableConnection(Socket socket, Listener listener) throws IOException { + super(listener); + this.socket = socket; + this.is = new DataInputStream(socket.getInputStream()); + this.os = new DataOutputStream(socket.getOutputStream()); + } + + protected void writeMessagePiece(MessagePiece piece) throws IOException { + byte[] bytes = MessagePiece.ADAPTER.encode(piece); + os.writeInt(bytes.length); + os.write(bytes); + } + + protected MessagePiece readMessagePiece() throws IOException { + int len = is.readInt(); + if (len <= 0 || len > MAX_PIECE_SIZE) { + throw new IOException("Piece size " + len + " exceeded limit of " + MAX_PIECE_SIZE + " bytes."); + } + byte[] bytes = new byte[len]; + is.readFully(bytes); + return MessagePiece.ADAPTER.decode(bytes); + } + + @Override + public void close() throws IOException { + socket.close(); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/TransportConnectionHandler.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/TransportConnectionHandler.java new file mode 100644 index 0000000000..459641aeef --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/TransportConnectionHandler.java @@ -0,0 +1,303 @@ +package org.microg.gms.wearable; + +import android.util.Log; + +import com.google.android.gms.wearable.ConnectionConfiguration; +import com.google.android.gms.wearable.internal.NodeParcelable; + +import org.microg.gms.wearable.bluetooth.BluetoothWearableConnection; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.ControlMessage; +import org.microg.gms.wearable.proto.MessagePiece; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class TransportConnectionHandler { + private static final String TAG = "WearTransportHandler"; + + private static final long CTRL_FLUSH_DELAY_MS = 120; + + private final WearableImpl wearable; + private final ConnectionConfiguration config; + + private final AtomicBoolean ownsMigrationSlot = new AtomicBoolean(false); + + public TransportConnectionHandler(WearableImpl wearable, ConnectionConfiguration config) { + this.wearable = wearable; + this.config = config; + } + + public void handle(WearableConnection connection) { + String peerNodeId = null; + String peerNodeName = null; + WearableReader reader = null; + WearableWriter writer = null; + boolean migrationSucceeded = false; + + try { + if (config.address != null){ + try { + wearable.updateConfiguration(config); + Log.d(TAG, "Persisted address " + config.address); + } catch (Exception e) { + Log.w(TAG, "Could not persist address:" + e.getMessage()); + } + } + + NodeMigrationController migrationController = wearable.getMigrationController(); + + if (config.migrating) { + String fromNode = wearable.getClockworkNodePreferences().getPeerNodeId(); + if (!migrationController.startPhoneSwitchMigration( + config.nodeId != null ? config.nodeId : "?", fromNode + )) { + Log.e(TAG, "handle: Already migrating - aborting..."); + return; + } + + ownsMigrationSlot.set(true); + Log.i(TAG, "handle: Migration slot acquired for "+ config.nodeId); + } + + Connect peerConnect = connection.getPeerConnect(); + if (peerConnect != null) { + peerNodeId = peerConnect.id; + peerNodeName = peerConnect.name; + } else { + peerNodeId = config.peerNodeId; + peerNodeName = config.name; + } + + if (peerNodeId == null || peerNodeId.isEmpty()) { + Log.e(TAG, "Cannot resolve peer node-id, aborting"); + return; + } + + if (config.migrating) { + migrationController.startMigrationForNode(peerNodeId); + } + + Log.d(TAG, "Handling connection to peer " + peerNodeId + " (" + peerNodeName + ")"); + + config.peerNodeId = peerNodeId; + config.connected = true; + + wearable.setConnectionRestrictions(peerNodeId, config.connectionRestrictions); + +// if (config.nodeId == null || config.nodeId.isEmpty()) { +// config.nodeId = wearable.getLocalNodeId(); +// } + config.nodeId = peerNodeId; + + try { + wearable.updateConfiguration(config); + } catch (Exception e) { + Log.w(TAG, "Could not update config nodeId: " + e.getMessage()); + } + + if (config.migrating) { + suspendOldConnections(peerNodeId); + } + + Log.d(TAG, "Setting up writer/reader for " + peerNodeId); + + MessageHandler handler = new MessageHandler(wearable.getContext(), wearable, config); + handler.markHandshakeAlreadyDone(); + + writer = new WearableWriter(peerNodeId, connection); + + WearableConnection writerFacade = buildWriterFacade(writer, peerNodeId); + + final WearableWriter writerForHook = writer; + reader = new WearableReader(peerNodeId, connection, writerFacade, handler, + writerForHook::requestHeartbeat); + + + wearable.registerPeerWriter(peerNodeId, writer); + if (peerConnect != null) { + wearable.onConnectReceived(connection, config.nodeId, peerConnect); + } else { + Connect synthetic = new Connect.Builder() + .id(peerNodeId) + .name(peerNodeName != null ? peerNodeName : peerNodeId) + .build(); + wearable.onConnectReceived(connection, config.nodeId, synthetic); + } + + if (config.migrating) { + wearable.onMigrationSucceeded(peerNodeId, config); + migrationSucceeded = true; + } + + Log.d(TAG, "Starting writer for " + peerNodeId); + writer.start(); + + Log.d(TAG, "Starting reader for " + peerNodeId); + reader.start(); + + Log.d(TAG, "Blocking until network loop finishes for " + peerNodeId); + reader.awaitFinished(); + Log.i(TAG, "Network loop finished for " + peerNodeId); + } catch (Exception e) { + Log.e(TAG, "Unexpected error for " + config.address, e); + } finally { + if (peerNodeId != null + && wearable.getChannelManager() != null && !connection.isClosed()) { + try { + wearable.getChannelManager().sendCloseForAllChannels(peerNodeId); + } catch (Exception e) { + Log.w(TAG, "handle: sendCloseForAllChannels failed for " + peerNodeId + + ": " + e.getMessage()); + } + } + + if (peerNodeId != null) + wearable.getActiveConnections().remove(peerNodeId); + + if (writer != null) { + writer.close(); + writer.awaitFinished(); + } + + try { + connection.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing connection: " + e.getMessage()); + } + + if (peerNodeId != null) { + DataTransport dt = wearable.getDataTransport(peerNodeId); + if (dt != null) { + dt.onDisconnect(); + } + wearable.unregisterPeerTransport(peerNodeId); + + if (wearable.getChannelManager() != null) { + wearable.getChannelManager().onNodeDisconnected(peerNodeId); + } + + for (ConnectionConfiguration cfg : wearable.getConfigurations()) { + if (peerNodeId.equals(cfg.peerNodeId) || peerNodeId.equals(cfg.nodeId)) { + cfg.connected = false; + } + } + + config.peerNodeId = null; + config.connected = false; + + String displayName = (peerNodeName != null && !peerNodeName.isEmpty() ? peerNodeName : peerNodeId); + + wearable.onPeerDisconnected(new NodeParcelable(peerNodeId, displayName)); + } + + if (ownsMigrationSlot.getAndSet(false)) { + if (!migrationSucceeded) { + String failNode = peerNodeId != null ? peerNodeId : config.nodeId; + wearable.onMigrationFailed(failNode, true); + } + + if (peerNodeId != null) { + wearable.getMigrationController().markNodeMigrationCompleted(peerNodeId); + } + + Log.d(TAG, "handle: Migration slot released for " + config.nodeId + + " (succeeded=" + migrationSucceeded + ")"); + } + + Log.d(TAG, "finished for " + config.address); + } + } + + private void suspendOldConnections(String peerNodeId) { + Log.d(TAG, "Suspending old connections before migrating to " + peerNodeId); + + NodeMigrationController ctrl = wearable.getMigrationController(); + Map active = wearable.getActiveConnections(); + + for (String existing : new ArrayList<>(active.keySet())) { + if (existing.equals(peerNodeId)) continue; + + WearableConnection connection = active.get(existing); + if (connection == null) continue; + + Log.d(TAG, "Suspending old node " + existing); + + ctrl.suspendNode(existing); + + try { + connection.writeMessage(buildControlMessage(NodeMigrationController.CTRL_SUSPEND_SYNC)); + } catch (IOException e) { + Log.w(TAG, "suspendOldConnections: SUSPEND_SYNC to " + existing + + " failed: " + e.getMessage()); + } + + try { + connection.writeMessage(buildControlMessage(NodeMigrationController.CTRL_TERMINATE_ASSOCIATION)); + } catch (IOException e) { + Log.w(TAG, "suspendOldConnections: TERMINATE_ASSOCIATION to " + existing + + " failed: " + e.getMessage()); + } + + try { + Thread.sleep(CTRL_FLUSH_DELAY_MS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + + try { + connection.close(); + } catch (IOException e) { + Log.w(TAG, "suspendOldConnections: close error for" + existing + + ": " + e.getMessage()); + } + + active.remove(existing); + Log.d(TAG, "suspendOldConnections: " + existing + " suspended adn connection closed"); + } + } + + private static RootMessage buildControlMessage(int type) { + return new RootMessage.Builder() + .controlMessage( + new ControlMessage.Builder().type(type).build() + ).build(); + } + + private static WearableConnection buildWriterFacade(WearableWriter writer, String nodeId) { + return new WearableConnection(new WearableConnection.Listener() { + @Override + public void onConnected(WearableConnection connection) {} + + @Override + public void onMessage(WearableConnection connection, RootMessage message) {} + + @Override + public void onDisconnected() {} + }) { + @Override + public void writeMessage(RootMessage message) { + writer.enqueue(message); + } + + @Override + protected void writeMessagePiece(MessagePiece piece) throws IOException { + throw new UnsupportedOperationException("write-only facade for " + nodeId); + } + + @Override + protected MessagePiece readMessagePiece() throws IOException { + throw new UnsupportedOperationException("write-only facade for " + nodeId); + } + + @Override + public void close() throws IOException { + writer.close(); + } + }; + } + +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearFastPairManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearFastPairManager.java new file mode 100644 index 0000000000..558e6df779 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearFastPairManager.java @@ -0,0 +1,70 @@ +package org.microg.gms.wearable; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; +import android.util.Log; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class WearFastPairManager { + private static final String TAG = "WearFastPairManager"; + private static final String PREFS = "wearable_fast_pair_account_keys"; + private static final int ACCOUNT_KEY_LENGTH = 16; + private static final byte ACCOUNT_KEY_PREFIX = 0x04; + + public static final class AccountKeyRecord { + public final String accountName; + public final byte[] accountKey; + + AccountKeyRecord(String accountName, byte[] accountKey) { + this.accountName = accountName; + this.accountKey = accountKey; + } + } + + public final Context context; + private final SecureRandom random = new SecureRandom(); + + public WearFastPairManager(Context context) { + this.context = context; + } + + private SharedPreferences prefs() { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + public synchronized List getAccountKeys() { + List result = new ArrayList<>(); + for (Map.Entry e : prefs().getAll().entrySet()) { + if (!(e.getValue() instanceof String)) continue; + try { + result.add(new AccountKeyRecord(e.getKey(), Base64.decode((String) e.getValue(), Base64.NO_WRAP))); + } catch (IllegalArgumentException ex) { + Log.w(TAG, "Skipping malformed account key for " + e.getKey()); + } + } + return result; + } + + public synchronized AccountKeyRecord getAccountKey(String accountName) { + if (accountName == null) return null; + for (AccountKeyRecord r : getAccountKeys()) { + if (accountName.equals(r.accountName)) return r; + } + return null; + } + + public synchronized AccountKeyRecord getOrCreateAccountKey(String accountName) { + AccountKeyRecord existing = getAccountKey(accountName); + if (existing != null) return existing; + byte[] key = new byte[ACCOUNT_KEY_LENGTH]; + random.nextBytes(key); + key[0] = ACCOUNT_KEY_PREFIX; + prefs().edit().putString(accountName, Base64.encodeToString(key, Base64.NO_WRAP)).apply(); + return new AccountKeyRecord(accountName, key); + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnection.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnection.java new file mode 100644 index 0000000000..0a02dee405 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnection.java @@ -0,0 +1,207 @@ +/* + * SPDX-FileCopyrightText: 2015, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import android.util.Log; + +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.MessagePiece; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import okio.ByteString; + +public abstract class WearableConnection implements Runnable { + private static String B64ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + private final String TAG = "WearableConnection"; + + public static final int DEFAULT_MAX_PIECE_SIZE = 12288; + private final Object writeLock = new Object(); + + private HashMap> piecesQueues = new HashMap>(); + private final Listener listener; + private Connect peerConnect = null; + + public WearableConnection(Listener listener) { + this.listener = listener; + } + + protected int getMaxPieceSize() { + return DEFAULT_MAX_PIECE_SIZE; + } + + protected int getWriteQueueId() { + return 0; + } + + public static String base64encode(byte[] bytes) { + int paddingCount = (3 - (bytes.length % 3)) % 3; + byte[] padded = new byte[bytes.length + paddingCount]; + System.arraycopy(bytes, 0, padded, 0, bytes.length); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < bytes.length; i += 3) { + int j = ((padded[i] & 0xff) << 16) + ((padded[i + 1] & 0xff) << 8) + (padded[i + 2] & 0xff); + sb.append(B64ALPHABET.charAt((j >> 18) & 0x3f)).append(B64ALPHABET.charAt((j >> 12) & 0x3f)) + .append(B64ALPHABET.charAt((j >> 6) & 0x3f)).append(B64ALPHABET.charAt(j & 0x3f)); + } + return sb.substring(0, sb.length() - paddingCount); + } + + public static String calculateDigest(byte[] bytes) { + try { + return base64encode(MessageDigest.getInstance("SHA1").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA1 not supported => platform not supported"); + } + } + + public void writeMessage(RootMessage message) throws IOException { + byte[] bytes = RootMessage.ADAPTER.encode(message); + final String digest = calculateDigest(bytes); + final int maxPieceSize = Math.max(1, getMaxPieceSize()); + final int queueId = getWriteQueueId(); + final int total = Math.max(1, (bytes.length + maxPieceSize - 1) / maxPieceSize); + + synchronized (writeLock) { + for (int i = 0; i < total; i++) { + int offset = i * maxPieceSize; + int length = Math.min(bytes.length - offset, maxPieceSize); + writeMessagePiece(new MessagePiece.Builder() + .data(ByteString.of(bytes, offset, length)) + .digest(digest) + .thisPiece(i + 1) + .totalPieces(total) + .queueId(queueId) + .build() + ); + } + } + + if (total > 1) { + Log.d(TAG, "writeMessage: sent " + bytes.length + " bytes as " + total + + " pieces (queueId=" + queueId + ", digest=" + digest + ")"); + } + } + + protected abstract void writeMessagePiece(MessagePiece piece) throws IOException; + + protected RootMessage readMessage() throws IOException { + while (true) { + MessagePiece piece = readMessagePiece(); + if (piece.totalPieces == 1) { + byte[] payload = piece.data.toByteArray(); + String calc = calculateDigest(payload); + if (!calc.equals(piece.digest)) { + throw new IOException("Digest mismatch for single-piece message"); + } + return RootMessage.ADAPTER.decode(piece.data); + } + + synchronized (piecesQueues) { + List queue = piecesQueues.get(piece.queueId); + + if (piece.thisPiece == 1) { + if (queue != null) { + piecesQueues.remove(piece.queueId); + } + queue = new ArrayList<>(piece.totalPieces); + queue.add(piece); + piecesQueues.put(piece.queueId, queue); + continue; + } + + if (queue == null || !queue.get(0).digest.equals(piece.digest)) { + piecesQueues.remove(piece.queueId); + throw new IOException("Received " + piece.thisPiece + " before first piece."); + } + + if (queue.size() + 1 != piece.thisPiece) { + piecesQueues.remove(piece.queueId); + throw new IOException("Received " + piece.thisPiece + " but expected piece" + queue.size() + 1); + } + + queue.add(piece); + + if (piece.thisPiece == piece.totalPieces) { + piecesQueues.remove(piece.queueId); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + + for (MessagePiece messagePiece : queue) { + messagePiece.data.write(bos); + } + + byte[] bytes = bos.toByteArray(); + if (!calculateDigest(bytes).equals(piece.digest)) { + throw new IOException("Merged pieces have digest " + calculateDigest(bytes) + ", but should be " + piece.digest); + } + + return RootMessage.ADAPTER.decode(bytes); + } + + } + } + } + + protected abstract MessagePiece readMessagePiece() throws IOException; + + public abstract void close() throws IOException; + + public Connect getPeerConnect() { + return peerConnect; + } + + public void setPeerConnect(Connect peerConnect) { + this.peerConnect = peerConnect; + } + public String getRemoteAddress() { return null; } + @Override + public void run() { + try { + listener.onConnected(this); + RootMessage message; + while ((message = readMessage()) != null) { + try { + listener.onMessage(this, message); + } catch (Exception e) { + Log.e(TAG, "Error processing message", e); + } + } + } catch (IOException e) { + Log.e(TAG, "Connection error", e); + } catch (Exception e) { + Log.e(TAG, "Unexpected error in connection", e); + } finally { + System.out.println("WearableConnection closed"); + try { + listener.onDisconnected(); + } catch (Exception e) { + Log.e(TAG, "Error in onDisconnected callback", e); + } + } + } + + public static final Listener NOOP = new Listener() { + @Override public void onConnected(WearableConnection c) {} + @Override public void onMessage(WearableConnection c, RootMessage m) {} + @Override public void onDisconnected() {} + }; + public interface Listener { + void onConnected(WearableConnection connection); + void onMessage(WearableConnection connection, RootMessage message); + void onDisconnected(); + } + + public boolean isClosed() { + return false; + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java index 1f0ed12669..c75ac5a82f 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java @@ -16,12 +16,19 @@ package org.microg.gms.wearable; +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.content.pm.ResolveInfo; import android.database.Cursor; import android.net.Uri; import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; import android.os.Looper; import android.os.RemoteException; import android.text.TextUtils; @@ -29,11 +36,19 @@ import android.util.Log; import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; import com.google.android.gms.common.data.DataHolder; import com.google.android.gms.wearable.Asset; import com.google.android.gms.wearable.ConnectionConfiguration; +import com.google.android.gms.wearable.ConnectionRestrictions; +import com.google.android.gms.wearable.DataItem; +import com.google.android.gms.wearable.MessageOptions; import com.google.android.gms.wearable.Node; +import com.google.android.gms.wearable.internal.CapabilityInfoParcelable; +import com.google.android.gms.wearable.internal.ChannelEventParcelable; +import com.google.android.gms.wearable.internal.ChannelParcelable; +import com.google.android.gms.wearable.internal.IRpcResponseCallback; import com.google.android.gms.wearable.internal.IWearableListener; import com.google.android.gms.wearable.internal.MessageEventParcelable; import com.google.android.gms.wearable.internal.NodeParcelable; @@ -42,18 +57,25 @@ import org.microg.gms.common.PackageUtils; import org.microg.gms.common.RemoteListenerProxy; import org.microg.gms.common.Utils; -import org.microg.wearable.SocketConnectionThread; -import org.microg.wearable.WearableConnection; -import org.microg.wearable.proto.AckAsset; -import org.microg.wearable.proto.AppKey; -import org.microg.wearable.proto.AppKeys; -import org.microg.wearable.proto.Connect; -import org.microg.wearable.proto.FetchAsset; -import org.microg.wearable.proto.FilePiece; -import org.microg.wearable.proto.Request; -import org.microg.wearable.proto.RootMessage; -import org.microg.wearable.proto.SetAsset; -import org.microg.wearable.proto.SetDataItem; +import org.microg.gms.wearable.bluetooth.BleManager; +import org.microg.gms.wearable.bluetooth.BluetoothClient; +import org.microg.gms.wearable.bluetooth.BluetoothServer; +import org.microg.gms.wearable.network.NetworkConnectionManager; +import org.microg.gms.wearable.channel.ChannelAssetApiEnum; +import org.microg.gms.wearable.channel.ChannelCallbacks; +import org.microg.gms.wearable.channel.ChannelManager; +import org.microg.gms.wearable.channel.ChannelToken; +import org.microg.gms.wearable.channel.TrustedPeersService; +import org.microg.gms.wearable.network.WearableWifiService; +import org.microg.gms.wearable.proto.AppKey; +import org.microg.gms.wearable.proto.AppKeys; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.ControlMessage; +import org.microg.gms.wearable.proto.FilePiece; +import org.microg.gms.wearable.proto.Request; +import org.microg.gms.wearable.proto.RootMessage; +import org.microg.gms.wearable.proto.SetAsset; +import org.microg.gms.wearable.proto.SetDataItem; import java.io.File; import java.io.FileInputStream; @@ -66,32 +88,64 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import okio.ByteString; public class WearableImpl { + public static final int WEAR_TCP_PORT = 5601; + public static final int TYPE_BLUETOOTH_RFCOMM = 1; + public static final int TYPE_NETWORK = 2; + public static final int TYPE_BLE = 3; + public static final int TYPE_CLOUD = 4; + public static final int ROLE_CLIENT = 1; + public static final int ROLE_SERVER = 2; private static final String TAG = "GmsWear"; - - private static final int WEAR_TCP_PORT = 5601; - + private static final long ASSET_FETCH_COOLDOWN_MS = 500; + private static final int ASSET_BATCH_SIZE = 10; + private static final long NODE_DISCONNECT_DEBOUNCE_MS = 2500; + private static final long ASSET_FETCH_INITIAL_DELAY_MS = 1500; private final Context context; private final NodeDatabaseHelper nodeDatabase; private final ConfigurationDatabaseHelper configDatabase; - private final Map> listeners = new HashMap>(); - private final Set connectedNodes = new HashSet(); - private final Map activeConnections = new HashMap(); + private final Map> listeners = new ConcurrentHashMap<>(); + private final Set connectedNodes = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private final Map activeConnections = new ConcurrentHashMap<>(); + private final Handler disconnectDebounceHandler = new Handler(Looper.getMainLooper()); + private final Map pendingDisconnects = new ConcurrentHashMap<>(); + private final Map peerTransports = new ConcurrentHashMap<>(); + private final AssetManager assetManager = new AssetManager(this); + private final Map pendingRpcRequests = new ConcurrentHashMap<>(); + private final Map peerWriters = new ConcurrentHashMap<>(); + public Handler networkHandler; private RpcHelper rpcHelper; private SocketConnectionThread sct; private ConnectionConfiguration[] configurations; private boolean configurationsUpdated = false; private ClockworkNodePreferences clockworkNodePreferences; private CountDownLatch networkHandlerLock = new CountDownLatch(1); - public Handler networkHandler; + private HandlerThread networkHandlerThread; + private BluetoothClient bluetoothClient; + private BluetoothServer bluetoothServer; + private BleManager bleManager; + private volatile ChannelManager channelManager; + private NodeMigrationController migrationController; + private NodeMigrationTracker migrationTracker; + private volatile long lastAssetFetchTime = 0; + private AssetFetcher assetFetcher; + private NetworkConnectionManager networkManager; + + private WearableWifiService wearWifiService; + + private RpcMessageTransport rpcTransport; + + private final Map nodeRestrictions = new ConcurrentHashMap<>(); public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, ConfigurationDatabaseHelper configDatabase) { this.context = context; @@ -99,15 +153,165 @@ public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, Configurat this.configDatabase = configDatabase; this.clockworkNodePreferences = new ClockworkNodePreferences(context); this.rpcHelper = new RpcHelper(context); + this.rpcTransport = new RpcMessageTransport(rpcHelper, new RpcMessageTransport.Host() { + @Override public String getLocalNodeId() { return WearableImpl.this.getLocalNodeId(); } + @Override public WearableWriter getWriter(String nodeId) { return peerWriters.get(nodeId); } + @Override public void onWriteFailed(String nodeId) { closeConnection(nodeId); } + }); + this.migrationTracker = new NodeMigrationTracker(nodeDatabase); + + Map best = new HashMap<>(); + for (ConnectionConfiguration c : configDatabase.getAllConfigurations()) { + if (c.address == null) continue; + String key = c.address.toLowerCase(); + ConnectionConfiguration prev = best.get(key); + if (prev == null) { + best.put(key, c); + continue; + } + + boolean prevIsAddr = prev.name != null && prev.name.equalsIgnoreCase(prev.address); + boolean curIsAddr = c.name != null && c.name.equalsIgnoreCase(c.address); + if (prevIsAddr && !curIsAddr) { + configDatabase.deleteConfiguration(prev.name); + best.put(key, c); + } else { + configDatabase.deleteConfiguration(c.name); + } + } + + configurationsUpdated = true; + + networkHandlerThread = new HandlerThread("wearNetworkHandler"); + networkHandlerThread.start(); + networkHandler = new Handler(networkHandlerThread.getLooper()); + networkHandlerLock.countDown(); + new Thread(() -> { - Looper.prepare(); - networkHandler = new Handler(Looper.myLooper()); - networkHandlerLock.countDown(); - Looper.loop(); + try { + networkHandlerLock.await(); + TrustedPeersService trustedPeers = new TrustedPeersService(context); + channelManager = new ChannelManager(networkHandler, this, getLocalNodeId(), trustedPeers); + WearableChannelCallbacks callbacks = new WearableChannelCallbacks(); + channelManager.setChannelCallbacks(callbacks); + channelManager.setCallbacks(ChannelAssetApiEnum.ORIGIN_CHANNEL_API, callbacks); + channelManager.start(); + } catch (InterruptedException e) { + Log.w(TAG, "Failed to initialize ChannelManager", e); + } }).start(); + + this.migrationController = new NodeMigrationController(); + this.assetFetcher = new AssetFetcher(nodeDatabase, networkHandler); + + this.wearWifiService = new WearableWifiService(context, this); + } + + public WearableWifiService getWearWifiService() { + return wearWifiService; + } + + public AssetManager getAssetManager() { + return assetManager; + } + + public NodeMigrationTracker getMigrationTracker() { + return migrationTracker; + } + + public ChannelManager getChannelManager() { + return channelManager; + } + + public AppKey getAppKey(String packageName) { + String signatureDigest = PackageUtils.firstSignatureDigest(context, packageName); + return new AppKey(packageName, signatureDigest); + } + + public void startNodeMigration(String newNodeId, String migratingFromId) { + Log.d(TAG, "startNodeMigration: node=" + newNodeId + " from=" + migratingFromId); + try { + android.database.sqlite.SQLiteDatabase db = nodeDatabase.getWritableDatabase(); + migrationTracker.updateMigrationInfo(db, newNodeId, migratingFromId); + } catch (android.database.sqlite.SQLiteException e) { + Log.e(TAG, "DB error while recording node migration state", e); + return; + } + migrationController.startMigrationForNode(newNodeId); + } + + public void terminateAssociation(String nodeId, boolean removeBond, String reason) { + Log.d(TAG, "terminateAssociation: node=" + nodeId + + ", removeBond=" + removeBond + ", reason=" + reason); + + ConnectionConfiguration target = null; + for (ConnectionConfiguration cfg : getConfigurations()) { + if (nodeId.equals(cfg.nodeId) || nodeId.equals(cfg.peerNodeId)) { + target = cfg; + break; + } + } + + if (target == null) { + Log.i(TAG, "terminateAssociation: no config found for node " + nodeId); + return; + } + + WearableConnection conn = activeConnections.get(nodeId); + if (conn != null) { + try { + conn.close(); + } catch (java.io.IOException ignored) { + } + activeConnections.remove(nodeId); + onPeerDisconnected(new com.google.android.gms.wearable.internal.NodeParcelable(nodeId, target.name)); + } + + deleteConnection(target.name); + + if (removeBond && target.address != null) { + try { + android.bluetooth.BluetoothAdapter adapter = android.bluetooth.BluetoothAdapter.getDefaultAdapter(); + if (adapter != null) { + android.bluetooth.BluetoothDevice device = adapter.getRemoteDevice(target.address); + if (device != null) { + java.lang.reflect.Method m = device.getClass().getMethod("removeBond"); + m.invoke(device); + Log.d(TAG, "Removed BT bond for " + target.address); + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to remove BT bond for " + target.address, e); + } + } + } + + public void registerPeerWriter(String peerNodeId, WearableWriter writer) { + DataTransport transport = peerTransports.get(peerNodeId); + + if (transport == null) { + transport = new DataTransport(getLocalNodeId(), peerNodeId, this); + peerTransports.put(peerNodeId, transport); + } + + transport.onConnected(writer); + Log.d(TAG, "registerPeerWriter for " + peerNodeId); + + peerWriters.put(peerNodeId, writer); + assetManager.addWriter(peerNodeId, writer); + } + + public DataTransport getDataTransport(String peerNodeId) { + return peerTransports.get(peerNodeId); } public String getLocalNodeId() { +// ConnectionConfiguration[] cfgs = getConfigurations(); +// if (cfgs != null) { +// for (ConnectionConfiguration c : cfgs) { +// if (c.nodeId != null && !c.nodeId.isEmpty()) return c.nodeId; +// } +// } return clockworkNodePreferences.getLocalNodeId(); } @@ -119,24 +323,94 @@ public DataItemRecord putDataItem(String packageName, String signatureDigest, St record.source = source; record.dataItem = dataItem; record.v1SeqId = clockworkNodePreferences.getNextSeqId(); - if (record.source.equals(getLocalNodeId())) record.seqId = record.v1SeqId; + record.seqId = record.v1SeqId; nodeDatabase.putRecord(record); return record; } - public DataItemRecord putDataItem(DataItemRecord record) { - nodeDatabase.putRecord(record); - if (!record.assetsAreReady) { - for (Asset asset : record.dataItem.getAssets().values()) { - if (!nodeDatabase.hasAsset(asset)) { - Log.d(TAG, "Asset is missing: " + asset); + public void dispatchDataChanged(DataItemRecord record) { + Intent intent = new Intent("com.google.android.gms.wearable.DATA_CHANGED"); + intent.setPackage(record.packageName); + intent.setData(record.dataItem.uri); + if (migrationController.shouldDeliverEvents(record.packageName, record.source)) { + invokeListeners(intent, l -> l.onDataChanged(record.toEventDataHolder())); + } else { + Log.d(TAG, "Suppressing DATA_CHANGED for " + record.packageName + " from migrating node " + record.source); + } + } + + private void maybeDispatchCapabilityChanged(DataItemRecord record) { + String path = record.dataItem.path; + if (path == null || !path.startsWith("/capabilities/")) return; + + String[] segments = path.split("/"); + if (segments.length < 5) return; + + String capabilityName = Uri.decode(segments[4]); + if (capabilityName == null || capabilityName.isEmpty()) return; + + Cursor cursor = nodeDatabase.getDataItemsByHostAndPath( + record.packageName, record.signatureDigest, null, + "/capabilities/" + segments[3] + "/" + segments[4] + ); + + Set nodeIds = new HashSet<>(); + if (cursor != null) { + while (cursor.moveToNext()) { + DataItemRecord r = DataItemRecord.fromCursor(cursor); + if (!r.deleted && r.dataItem.host != null) { + nodeIds.add(r.dataItem.host); } } + cursor.close(); + } + + List nodes = new ArrayList<>(); + for (String nid : nodeIds) { + ConnectionConfiguration cfg = getConfigurationByNodeId(nid); + if (cfg == null) { + cfg = getConfigurationByPeerNodeId(nid); + } + + String name = (cfg != null && cfg.name != null) ? cfg.name : nid; + nodes.add(new NodeParcelable(nid, name)); + } + + CapabilityInfoParcelable capInfo = new CapabilityInfoParcelable(capabilityName, nodes); + Intent capIntent = new Intent("com.google.android.gms.wearable.CAPABILITY_CHANGED", + new Uri.Builder().scheme("wear").authority("").path(capabilityName).build()); + + capIntent.setPackage(record.packageName); + invokeListeners(capIntent, listener -> listener.onConnectedCapabilityChanged(capInfo)); + + } + + public DataItemRecord putDataItem(DataItemRecord record) { + boolean allAssetsPresent = true; + for (Asset asset : record.dataItem.getAssets().values()) { + String digest = asset.getDigest(); + if (digest != null && !assetFileExists(digest)) { + Log.d(TAG, "Asset is missing: " + asset); + allAssetsPresent = false; + nodeDatabase.markAssetAsMissing(digest, record.packageName, record.signatureDigest); + } } + record.assetsAreReady = allAssetsPresent; + + nodeDatabase.putRecord(record); + + maybeDispatchCapabilityChanged(record); + Intent intent = new Intent("com.google.android.gms.wearable.DATA_CHANGED"); intent.setPackage(record.packageName); intent.setData(record.dataItem.uri); - invokeListeners(intent, listener -> listener.onDataChanged(record.toEventDataHolder())); + if (migrationController.shouldDeliverEvents(record.packageName, record.source)) { + invokeListeners(intent, listener -> listener.onDataChanged(record.toEventDataHolder())); + } else { + Log.d(TAG, "Suppressing DATA_CHANGED for " + record.packageName + + " from migrating node " + record.source); + } + return record; } @@ -175,17 +449,14 @@ private Asset prepareAsset(String packageName, Asset asset) { } public File createAssetFile(String digest) { + if (TextUtils.isEmpty(digest)) { + throw new IllegalArgumentException("createAssetFile: digest must not be null or empty"); + } File dir = new File(new File(context.getFilesDir(), "assets"), digest.substring(digest.length() - 2)); dir.mkdirs(); return new File(dir, digest + ".asset"); } - private File createAssetReceiveTempFile(String name) { - File dir = new File(context.getFilesDir(), "piece"); - dir.mkdirs(); - return new File(dir, name); - } - private String calculateDigest(byte[] data) { try { return Base64.encodeToString(MessageDigest.getInstance("SHA1").digest(data), Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE); @@ -194,6 +465,52 @@ private String calculateDigest(byte[] data) { } } + public synchronized ConnectionConfiguration getConfigurationByName(String name) { + if (configurations == null) { + configurations = configDatabase.getAllConfigurations(); + } + + for (ConnectionConfiguration configuration : configurations) { + if (configuration.name != null && configuration.name.equals(name)) { + return configuration; + } + } + + return null; + } + + public synchronized ConnectionConfiguration getConfigurationByNodeId(String nodeId) { + if (configurations == null) { + configurations = configDatabase.getAllConfigurations(); + } + + for (ConnectionConfiguration configuration : configurations) { + if (configuration.nodeId != null && configuration.nodeId.equals(nodeId)) return configuration; + } + + return null; + } + + public synchronized ConnectionConfiguration getConfigurationByPeerNodeId(String peerNodeId) { + if (configurations == null) configurations = configDatabase.getAllConfigurations(); + for (ConnectionConfiguration c : configurations) { + if (peerNodeId.equals(c.peerNodeId)) return c; + } + return null; + } + + public synchronized ConnectionConfiguration getConfigurationByAddress(String address) { + if (configurations == null) { + configurations = configDatabase.getAllConfigurations(); + } + + for (ConnectionConfiguration configuration : configurations) { + if (configuration.address.equals(address)) return configuration; + } + + return null; + } + public synchronized ConnectionConfiguration[] getConfigurations() { if (configurations == null) { configurations = configDatabase.getAllConfigurations(); @@ -203,21 +520,105 @@ public synchronized ConnectionConfiguration[] getConfigurations() { ConnectionConfiguration[] newConfigurations = configDatabase.getAllConfigurations(); for (ConnectionConfiguration configuration : configurations) { for (ConnectionConfiguration newConfiguration : newConfigurations) { - if (newConfiguration.name.equals(configuration.name)) { - newConfiguration.connected = configuration.connected; - newConfiguration.peerNodeId = configuration.peerNodeId; - newConfiguration.nodeId = configuration.nodeId; + if (newConfiguration.address != null && + newConfiguration.address.equalsIgnoreCase(configuration.address)) { + + if (newConfiguration.peerNodeId == null && configuration.peerNodeId != null) + newConfiguration.peerNodeId = configuration.peerNodeId; + + if (!newConfiguration.connected && configuration.connected) + newConfiguration.connected = true; + + if (newConfiguration.nodeId == null && configuration.nodeId != null) + newConfiguration.nodeId = configuration.nodeId; + break; } } } configurations = newConfigurations; } + + Map peerToAddress = new HashMap<>(); + for (ConnectionConfiguration c : configurations) { + if (c.peerNodeId != null && c.address != null) { + peerToAddress.put(c.peerNodeId, c.address); + } + } + +// for (String activePeerId : activeConnections.keySet()) { +// String addr = peerToAddress.get(activePeerId); +// if (addr == null) continue; +// for (ConnectionConfiguration c : configurations) { +// if (addr.equalsIgnoreCase(c.address) && !c.connected) { +// c.connected = true; +// c.peerNodeId = activePeerId; +// } +// } +// } + + for (Map.Entry e : activeConnections.entrySet()) { + String activePeerId = e.getKey(); + String addr = peerToAddress.get(activePeerId); + if (addr != null) { + for (ConnectionConfiguration c : configurations) { + if (addr.equalsIgnoreCase(c.address) && !c.connected) { + c.connected = true; + c.peerNodeId = activePeerId; + } + } + continue; + } + String remoteAddr = e.getValue().getRemoteAddress(); + if (remoteAddr == null) continue; + for (ConnectionConfiguration c : configurations) { + if (remoteAddr.equalsIgnoreCase(c.address) && !c.connected) { + Log.d(TAG, "getConfigurations: live-overlay first-connect" + + " addr=" + remoteAddr + " peer=" + activePeerId); + c.connected = true; + c.peerNodeId = activePeerId; + } + } + } + + // companion app crash if name is null + // name can be null due failed pair (maybe), + // or maybe i something broke, + // or not setting name properly somewhere + // so we just set address as name + for (int i = 0; i < configurations.length; i++) { + ConnectionConfiguration c = configurations[i]; + boolean reachable = (c.peerNodeId != null && activeConnections.containsKey(c.peerNodeId)) + || (c.nodeId != null && activeConnections.containsKey(c.nodeId)) + || (c.peerNodeId != null && pendingDisconnects.containsKey(c.peerNodeId)) + || (c.nodeId != null && pendingDisconnects.containsKey(c.nodeId)); + String fixedName = c.name; + if (fixedName == null || fixedName.isEmpty() || "null".equals(fixedName)) + fixedName = (c.address != null) ? c.address : "Unknown"; + if (reachable != c.connected || !fixedName.equals(c.name)) { + configurations[i] = new ConnectionConfiguration( + fixedName, c.address, c.type, c.role, c.enabled, + reachable, c.peerNodeId, c.btlePriority, + c.nodeId, c.packageName, c.connectionRetryStrategy, + c.allowedConfigPackages, c.migrating, + c.dataItemSyncEnabled, c.connectionRestrictions, + c.removeConnectionWhenBondRemovedByUser, + c.connectionDelayFilters, + c.maxSupportedRemoteAndroidSdkVersion, c.runtimeType); + } + } + + Log.d(TAG, "Configurations reported: " + Arrays.toString(configurations)); return configurations; } private void addConnectedNode(Node node) { + Runnable pending = pendingDisconnects.remove(node.getId()); + if (pending != null) { + disconnectDebounceHandler.removeCallbacks(pending); + Log.d(TAG, "addConnectedNode: cancelled debounced disconnect for " + node.getId()); + } connectedNodes.add(node); onConnectedNodes(getConnectedNodesParcelableList()); } @@ -227,7 +628,28 @@ private void removeConnectedNode(String nodeId) { if (connectedNode.getId().equals(nodeId)) connectedNodes.remove(connectedNode); } - onConnectedNodes(getConnectedNodesParcelableList()); + + List nowNodes = getConnectedNodesParcelableList(); + if (!nowNodes.isEmpty()) { + onConnectedNodes(nowNodes); + return; + } + + Runnable r = () -> { + pendingDisconnects.remove(nodeId); + List latestNodes = getConnectedNodesParcelableList(); + Log.d(TAG, "removeConnectedNode: debounce fired for " + nodeId + + ", current nodes=" + latestNodes); + onConnectedNodes(latestNodes); + }; + + Runnable old = pendingDisconnects.put(nodeId, r); + if (old != null) + disconnectDebounceHandler.removeCallbacks(old); + + disconnectDebounceHandler.postDelayed(r, NODE_DISCONNECT_DEBOUNCE_MS); + Log.d(TAG, "removeConnectedNode: debouncing disconnect broadcast for " + + nodeId + " (" + NODE_DISCONNECT_DEBOUNCE_MS + " ms)"); } @@ -248,19 +670,46 @@ public void syncToPeer(String peerNodeId, String nodeId, long seqId) { } - void syncRecordToAll(DataItemRecord record) { + public void syncRecordToAll(DataItemRecord record) { for (String nodeId : new ArrayList(activeConnections.keySet())) { syncRecordToPeer(nodeId, record); } } + public Map getActiveConnections() { + return activeConnections; + } + private boolean syncRecordToPeer(String nodeId, DataItemRecord record) { + if (migrationController.isNodeSuspended(nodeId)) { + Log.d(TAG, "syncRecordToPeer: " + nodeId + " is suspended, deferring"); + return true; + } + + ConnectionRestrictions restrictions = nodeRestrictions.get(nodeId); + if (restrictions != null && !ConnectionRestrictionFilter.isDataItemAllowed( + restrictions, record.packageName, + record.dataItem != null ? record.dataItem.uri : null)) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "syncRecordToPeer: filtered out " + record.packageName + " " + + (record.dataItem != null ? record.dataItem.uri : null) + + " for restricted node " + nodeId); + } + return true; + } + + WearableConnection connection = activeConnections.get(nodeId); + if (connection == null) { + Log.w(TAG, "Cannot sync to " + nodeId + " - connection not found"); + return false; + } + for (Asset asset : record.dataItem.getAssets().values()) { try { - syncAssetToPeer(nodeId, record, asset); + syncAssetToPeer(connection, record, asset); } catch (Exception e) { Log.w(TAG, "Could not sync asset " + asset + " for " + nodeId + " and " + record, e); - closeConnection(nodeId); +// closeConnection(nodeId); return false; } } @@ -276,25 +725,69 @@ private boolean syncRecordToPeer(String nodeId, DataItemRecord record) { return true; } - private void syncAssetToPeer(String nodeId, DataItemRecord record, Asset asset) throws IOException { + public void setConnectionRestrictions(String nodeId, ConnectionRestrictions restrictions) { + if (nodeId == null) return; + if (restrictions == null) { + nodeRestrictions.remove(nodeId); + return; + } + if (!ConnectionRestrictionFilter.isValid(restrictions)) { + Log.w(TAG, "Ignoring invalid ConnectionRestrictions for " + nodeId); + nodeRestrictions.remove(nodeId); + return; + } + Log.d(TAG, "Applying ConnectionRestrictions for " + nodeId + ": " + restrictions); + nodeRestrictions.put(nodeId, restrictions); + } + + public void clearConnectionRestrictions(String nodeId) { + if (nodeId != null) nodeRestrictions.remove(nodeId); + } + + private void syncAssetToPeer(WearableConnection connection, DataItemRecord record, Asset asset) throws IOException { + String digest = asset.getDigest(); + if (TextUtils.isEmpty(digest)) { + Log.w(TAG, "syncAssetToPeer: skipping asset with empty name for record " + record.dataItem.uri); + return; + } + RootMessage announceMessage = new RootMessage.Builder().setAsset(new SetAsset.Builder() - .digest(asset.getDigest()) - .appkeys(new AppKeys(Collections.singletonList(new AppKey(record.packageName, record.signatureDigest)))) - .build()).hasAsset(true).build(); - activeConnections.get(nodeId).writeMessage(announceMessage); + .digest(digest) + .appkeys( + new AppKeys( + Collections.singletonList( + new AppKey(record.packageName, record.signatureDigest) + ) + ) + ).build()).hasAsset(true).build(); + + connection.writeMessage(announceMessage); + File assetFile = createAssetFile(asset.getDigest()); - String fileName = calculateDigest(announceMessage.encode()); - FileInputStream fis = new FileInputStream(assetFile); - byte[] arr = new byte[12215]; - ByteString lastPiece = null; - int c = 0; - while ((c = fis.read(arr)) > 0) { - if (lastPiece != null) { - activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, false, lastPiece, null)).build()); + String filename = calculateDigest(announceMessage.encode()); + + try (FileInputStream fis = new FileInputStream(assetFile)) { + byte[] arr = new byte[12215]; + ByteString lastPiece = null; + int c; + while ((c = fis.read(arr)) > 0) { + if (lastPiece != null) { + connection.writeMessage( + new RootMessage.Builder() + .filePiece( + new FilePiece(filename, false, lastPiece, null) + ).build() + ); + } + lastPiece = ByteString.of(arr, 0, c); } - lastPiece = ByteString.of(arr, 0, c); + connection.writeMessage( + new RootMessage.Builder() + .filePiece( + new FilePiece(filename, true, lastPiece, asset.getDigest()) + ).build() + ); } - activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, true, lastPiece, asset.getDigest())).build()); } public void addAssetToDatabase(Asset asset, List appKeys) { @@ -308,122 +801,298 @@ public long getCurrentSeqId(String nodeId) { return nodeDatabase.getCurrentSeqId(nodeId); } - public void handleFilePiece(WearableConnection connection, String fileName, byte[] bytes, String finalPieceDigest) { - File file = createAssetReceiveTempFile(fileName); - try { - FileOutputStream fos = new FileOutputStream(file, true); - fos.write(bytes); - fos.close(); - } catch (IOException e) { - Log.w(TAG, e); - } - if (finalPieceDigest != null) { - // This is a final piece. If digest matches we're so happy! - try { - String digest = calculateDigest(Utils.readStreamToEnd(new FileInputStream(file))); - if (digest.equals(finalPieceDigest)) { - if (file.renameTo(createAssetFile(digest))) { - nodeDatabase.markAssetAsPresent(digest); - connection.writeMessage(new RootMessage.Builder().ackAsset(new AckAsset(digest)).build()); - } else { - Log.w(TAG, "Could not rename to target file name. delete=" + file.delete()); - } - } else { - Log.w(TAG, "Received digest does not match. delete=" + file.delete()); - } - } catch (IOException e) { - Log.w(TAG, "Failed working with temp file. delete=" + file.delete(), e); - } - } + public boolean assetFileExists(String digest) { + if (digest == null) return false; + File assetFile = createAssetFile(digest); + return assetFile.exists(); + } + + public File createAssetReceiveTempFile(String name) { + File dir = new File(context.getFilesDir(), "piece"); + dir.mkdirs(); + return new File(dir, name); } public void onConnectReceived(WearableConnection connection, String nodeId, Connect connect) { for (ConnectionConfiguration config : getConfigurations()) { - if (config.nodeId.equals(nodeId)) { - if (config.nodeId != nodeId) { - config.nodeId = connect.id; - configDatabase.putConfiguration(config, nodeId); - } + if ((config.nodeId != null && config.nodeId.equals(nodeId)) + || (config.nodeId == null && connect.id.equals(config.peerNodeId))) { config.peerNodeId = connect.id; config.connected = true; } } - Log.d(TAG, "Adding connection to list of open connections: " + connection + " with connect " + connect); - activeConnections.put(connect.id, connection); - onPeerConnected(new NodeParcelable(connect.id, connect.name)); - // Fetch missing assets - Cursor cursor = nodeDatabase.listMissingAssets(); - if (cursor != null) { - while (cursor.moveToNext()) { - try { - Log.d(TAG, "Fetch for " + cursor.getString(12)); - connection.writeMessage(new RootMessage.Builder() - .fetchAsset(new FetchAsset.Builder() - .assetName(cursor.getString(12)) - .packageName(cursor.getString(1)) - .signatureDigest(cursor.getString(2)) - .permission(false) - .build()).build()); - } catch (IOException e) { - Log.w(TAG, e); - closeConnection(connect.id); + + String remoteAddr = connection.getRemoteAddress(); + if (remoteAddr != null) { + boolean marked = false; + for (ConnectionConfiguration c : getConfigurations()) { + if (connect.id.equals(c.peerNodeId) && c.connected) { + marked = true; + break; + } + } + if (!marked) { + for (ConnectionConfiguration c : getConfigurations()) { + if (remoteAddr.equalsIgnoreCase(c.address)) { + Log.i(TAG, "onConnectReceived: address-match " + + " addr=" + remoteAddr + " peer=" + connect.id); + c.peerNodeId = connect.id; + c.connected = true; + break; + } } } - cursor.close(); } - } - public void onDisconnectReceived(WearableConnection connection, Connect connect) { + Log.d(TAG, "Adding connection to list of open connections: " + connection + + " with connect " + connect); + + String btAddress = null; for (ConnectionConfiguration config : getConfigurations()) { - if (config.nodeId.equals(connect.id)) { - config.connected = false; + if (connect.id.equals(config.peerNodeId) && config.address != null) { + btAddress = config.address; + break; } } - Log.d(TAG, "Removing connection from list of open connections: " + connection); - activeConnections.remove(connect.id); - onPeerDisconnected(new NodeParcelable(connect.id, connect.name)); - } - public List getConnectedNodesParcelableList() { - List nodes = new ArrayList(); - for (Node connectedNode : connectedNodes) { - nodes.add(new NodeParcelable(connectedNode)); - } - return nodes; - } + if (btAddress != null) { + Set staleNodeIds = new HashSet<>(); - interface ListenerInvoker { - void invoke(IWearableListener listener) throws RemoteException; - } + for (String existingId : activeConnections.keySet()) { + if (existingId.equals(connect.id)) + continue; - private void invokeListeners(@Nullable Intent intent, ListenerInvoker invoker) { - for (String packageName : new ArrayList<>(listeners.keySet())) { - List listeners = this.listeners.get(packageName); - if (listeners == null) continue; - for (int i = 0; i < listeners.size(); i++) { - boolean filterMatched = false; - if (intent != null) { - for (IntentFilter filter : listeners.get(i).filters) { - filterMatched |= filter.match(context.getContentResolver(), intent, false, TAG) > 0; + for (ConnectionConfiguration c : getConfigurations()) { + if (existingId.equals(c.peerNodeId) && btAddress.equals(c.address)) { + staleNodeIds.add(existingId); + break; } } - if (filterMatched || listeners.get(i).filters.length == 0) { - try { - invoker.invoke(listeners.get(i).listener); - } catch (RemoteException e) { - Log.w(TAG, "Registered listener at package " + packageName + " failed, removing."); - listeners.remove(i); - i--; + } + + for (Node n : connectedNodes) { + String existsingId = n.getId(); + if (existsingId.equals(connect.id)) + continue; + + for (ConnectionConfiguration c : getConfigurations()) { + if (existsingId.equals(c.peerNodeId) && btAddress.equals(c.address)) { + staleNodeIds.add(existsingId); + break; } } } - if (listeners.isEmpty()) { - this.listeners.remove(packageName); + + for (String staleId : staleNodeIds) { + Log.i(TAG, "onConnectReceived: evicting stale connection" + staleId + + " for address " + btAddress + " superseded by " + connect.id); + closeConnection(staleId); } } - if (intent != null) { - try { - invoker.invoke(RemoteListenerProxy.get(context, intent, IWearableListener.class, "com.google.android.gms.wearable.BIND_LISTENER")); + + activeConnections.put(connect.id, connection); + + onPeerConnected(new NodeParcelable(connect.id, connect.name, 0, true)); + + DataTransport dt = peerTransports.get(connect.id); + + + Handler h = networkHandler; + if (h != null && h.getLooper().getThread().isAlive()) { + h.postDelayed(() -> { + if (activeConnections.containsKey(connect.id)) { + fetchMissingAssets(connect.id); + } else { + Log.d(TAG, "Connection closed before asset fetch could start"); + } + }, ASSET_FETCH_INITIAL_DELAY_MS); + } else { + Log.w(TAG, "networkHandler is dead, skipping asset fetch scheduling for " + connect.id); + } + } + + private void fetchMissingAssets(String nodeId) { + long now = System.currentTimeMillis(); + long timeSinceLastFetch = now - lastAssetFetchTime; + if (timeSinceLastFetch < ASSET_FETCH_COOLDOWN_MS) { + long delay = ASSET_FETCH_COOLDOWN_MS - timeSinceLastFetch; + networkHandler.postDelayed(() -> doFetchMissingAssets(nodeId), delay); + return; + } + + doFetchMissingAssets(nodeId); + lastAssetFetchTime = now; + } + + private void doFetchMissingAssets(String nodeId) { + WearableConnection connection = activeConnections.get(nodeId); + + assetFetcher.fetchMissingAssets(nodeId, connection, activeConnections, channelManager); + } + + public AssetFetcher getAssetFetcher() { + return assetFetcher; + } + + public void onDisconnectReceived(WearableConnection connection, Connect connect) { + for (ConnectionConfiguration config : getConfigurations()) { + if (connect.id.equals(config.peerNodeId) || config.nodeId.equals(connect.id)) { + config.connected = false; + } + } + Log.d(TAG, "Removing connection from list of open connections: " + connection); + + if (channelManager != null) + channelManager.sendCloseForAllChannels(connect.id); + + activeConnections.remove(connect.id); + + DataTransport dt = peerTransports.remove(connect.id); + if (dt != null) { + dt.onDisconnect(); + } + + peerWriters.remove(connect.id); + assetManager.removeWriter(connect.id); + + if (channelManager != null) { + channelManager.onNodeDisconnected(connect.id); + } + + onPeerDisconnected(new NodeParcelable(connect.id, connect.name)); + } + + public Set getConnectedPeerNodeIds() { + return new HashSet<>(peerTransports.keySet()); + } + + public List getConnectedNodesParcelableList() { + List nodes = new ArrayList<>(); + for (Node connectedNode : connectedNodes) { + nodes.add(new NodeParcelable(connectedNode)); + } + + for (String peerId : new ArrayList<>(activeConnections.keySet())) { + boolean seen = false; + for (NodeParcelable n : nodes) { + if (n.getId().equals(peerId)) { + seen = true; + break; + } + } + if (!seen) { + ConnectionConfiguration cfg = getConfigurationByPeerNodeId(peerId); + String name = (cfg != null && cfg.name != null) ? cfg.name : peerId; + nodes.add(new NodeParcelable(peerId, name, 0, true)); + } + } + + return nodes; + } + + public boolean invokeRequestListeners(Intent intent, MessageEventParcelable event, IRpcResponseCallback responseCallback) { + boolean matched = false; + + for (String packageName : new ArrayList<>(listeners.keySet())) { + List packageListeners = listeners.get(packageName); + if (packageListeners == null) { + continue; + } + + for (int i = 0; i < packageListeners.size(); i++) { + ListenerInfo info = packageListeners.get(i); + + boolean filterMatched = false; + + if (intent != null) { + for (IntentFilter filter : info.filters) { + if (filter.match( + context.getContentResolver(), + intent, + false, + TAG) > 0) { + + filterMatched = true; + break; + } + } + } + + if (!filterMatched && info.filters.length != 0) { + continue; + } + + matched = true; + + try { + info.listener.onRequest(event, responseCallback); + } catch (RemoteException e) { + Log.w(TAG, "Registered REQUEST_RECEIVED listener at package " + + packageName + " failed, removing."); + + packageListeners.remove(i); + i--; + } + } + + if (packageListeners.isEmpty()) { + listeners.remove(packageName); + } + } + + if (matched) { + return true; + } + + if (intent != null) { + try { + IWearableListener remote = RemoteListenerProxy.get( + context, + intent, + IWearableListener.class, + "com.google.android.gms.wearable.BIND_LISTENER"); + + if (remote != null) { + remote.onRequest(event, responseCallback); + return true; + } + + } catch (RemoteException e) { + Log.w(TAG, "Failed to deliver REQUEST_RECEIVED to " + intent, e); + } + } + + return false; + } + + public void invokeListeners(@Nullable Intent intent, ListenerInvoker invoker) { + for (String packageName : new ArrayList<>(listeners.keySet())) { + List listeners = this.listeners.get(packageName); + if (listeners == null) continue; + for (int i = 0; i < listeners.size(); i++) { + boolean filterMatched = false; + if (intent != null) { + for (IntentFilter filter : listeners.get(i).filters) { + filterMatched |= filter.match(context.getContentResolver(), intent, false, TAG) > 0; + } + } + if (filterMatched || listeners.get(i).filters.length == 0) { + try { + invoker.invoke(listeners.get(i).listener); + } catch (RemoteException e) { + Log.w(TAG, "Registered listener at package " + packageName + " failed, removing."); + listeners.remove(i); + i--; + } + } + } + if (listeners.isEmpty()) { + this.listeners.remove(packageName); + } + } + if (intent != null) { + try { + invoker.invoke(RemoteListenerProxy.get(context, intent, IWearableListener.class, "com.google.android.gms.wearable.BIND_LISTENER")); } catch (RemoteException e) { Log.w(TAG, "Failed to deliver message received to " + intent, e); } @@ -432,19 +1101,43 @@ private void invokeListeners(@Nullable Intent intent, ListenerInvoker invoker) { public void onPeerConnected(NodeParcelable node) { Log.d(TAG, "onPeerConnected: " + node); - invokeListeners(null, listener -> listener.onPeerConnected(node)); + Uri uri = new Uri.Builder().scheme("wear").authority(node.getId()).build(); + Intent intent = new Intent("com.google.android.gms.wearable.NODE_CHANGED", uri); + invokeListeners(intent, listener -> listener.onPeerConnected(node)); addConnectedNode(node); } public void onPeerDisconnected(NodeParcelable node) { - Log.d(TAG, "onPeerDisconnected: " + node); - invokeListeners(null, listener -> listener.onPeerDisconnected(node)); - removeConnectedNode(node.getId()); + Log.d(TAG, "onPeerDisconnected (debouncing " + NODE_DISCONNECT_DEBOUNCE_MS + "ms): " + node); + scheduleDebouncedDisconnect(node); + } + + private void scheduleDebouncedDisconnect(NodeParcelable node) { + final String nodeId = node.getId(); + Runnable r = () -> { + pendingDisconnects.remove(nodeId); + for (Node n : new ArrayList<>(connectedNodes)) { + if (n.getId().equals(nodeId)) connectedNodes.remove(n); + } + Log.d(TAG, "scheduleDebouncedDisconnect: fired for " + + nodeId + " — broadcasting onPeerDisconnected" ); + Uri uri = new Uri.Builder().scheme("wear").authority(nodeId).build(); + Intent intent = new Intent("com.google.android.gms.wearable.NODE_CHANGED", uri); + invokeListeners(intent, listener -> listener.onPeerDisconnected(node)); + onConnectedNodes(getConnectedNodesParcelableList()); + }; + + Runnable old = pendingDisconnects.put(nodeId, r); + if (old != null) disconnectDebounceHandler.removeCallbacks(old); + disconnectDebounceHandler.postDelayed(r, NODE_DISCONNECT_DEBOUNCE_MS); } + public void onConnectedNodes(List nodes) { Log.d(TAG, "onConnectedNodes: " + nodes); - invokeListeners(null, listener -> listener.onConnectedNodes(nodes)); +// invokeListeners(null, listener -> listener.onConnectedNodes(nodes)); + Intent intent = new Intent("com.google.android.gms.wearable.NODE_CHANGED", Uri.parse("wear:///")); + invokeListeners(intent, listener -> listener.onConnectedNodes(nodes)); } public DataItemRecord putData(PutDataRequest request, String packageName) { @@ -462,6 +1155,26 @@ public DataItemRecord putData(PutDataRequest request, String packageName) { return record; } + public Set getNodesByCapabilityPath(String pathPrefix, String capabilityName) { + Set nodes = new HashSet<>(); + Cursor cursor = nodeDatabase.getDataItemsByCapabilityName(capabilityName); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + DataItemRecord r = DataItemRecord.fromCursor(cursor); + if (!r.deleted && r.dataItem.host != null + && r.dataItem.path != null + && r.dataItem.path.endsWith("/" + capabilityName)) { + nodes.add(r.dataItem.host); + } + } + } finally { + cursor.close(); + } + } + return nodes; + } + public DataHolder getDataItemsAsHolder(String packageName) { Cursor dataHolderItems = nodeDatabase.getDataItemsForDataHolder(packageName, PackageUtils.firstSignatureDigest(context, packageName)); return new DataHolder(dataHolderItems, 0, null); @@ -488,10 +1201,28 @@ public DataHolder getDataItemsByUriAsHolder(Uri uri, String packageName) { } public synchronized void addListener(String packageName, IWearableListener listener, IntentFilter[] filters) { - if (!listeners.containsKey(packageName)) { - listeners.put(packageName, new ArrayList()); + List list = listeners.get(packageName); + if (list == null) { + list = new ArrayList<>(); + listeners.put(packageName, list); + } + + IBinder incoming = listener.asBinder(); + for (int i = 0; i < list.size(); i++) { + if (list.get(i).listener.asBinder().equals(incoming)) { + list.set(i, new ListenerInfo(listener, filters)); + return; + } + } + + ListenerInfo info = new ListenerInfo(listener, filters); + list.add(info); + + try { + listener.asBinder().linkToDeath(() -> removeListener(listener), 0); + } catch (RemoteException e) { + list.remove(info); } - listeners.get(packageName).add(new ListenerInfo(listener, filters)); } public void removeListener(IWearableListener listener) { @@ -505,23 +1236,57 @@ public void removeListener(IWearableListener listener) { } } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void enableConnection(String name) { - configDatabase.setEnabledState(name, true); + Log.d(TAG, "enableConnection: " + name); + + ConnectionConfiguration config = getConfigurationByName(name); + + configDatabase.setEnabledState(config.name, true); configurationsUpdated = true; - if (name.equals("server") && sct == null) { - Log.d(TAG, "Starting server on :" + WEAR_TCP_PORT); - (sct = SocketConnectionThread.serverListen(WEAR_TCP_PORT, new MessageHandler(context, this, configDatabase.getConfiguration(name)))).start(); + + switch (config.type) { + case TYPE_CLOUD: + return; // abort on cloud type + case TYPE_BLUETOOTH_RFCOMM: + case 5: + handleLegacy(config, true); + break; + case TYPE_NETWORK: + handleNetwork(config, true); + break; + case TYPE_BLE: + handleBle(config, true); + break; + default: + Log.w(TAG, "unimplemented config type: " + config.type); } } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void disableConnection(String name) { + Log.d(TAG, "disableConnection: " + name); + configDatabase.setEnabledState(name, false); configurationsUpdated = true; - if (name.equals("server") && sct != null) { - activeConnections.remove(sct.getWearableConnection()); - sct.close(); - sct.interrupt(); - sct = null; + + ConnectionConfiguration config = configDatabase.getConfiguration(name); + + switch (config.type) { + case TYPE_CLOUD: + return; // abort on cloud type + case TYPE_BLUETOOTH_RFCOMM: + case 5: + handleLegacy(config, false); + break; + case TYPE_NETWORK: + handleNetwork(config, false); + break; + case TYPE_BLE: + handleBle(config, false); + break; + default: + Log.w(TAG, "unimplemented config type: " + config.type); } } @@ -530,11 +1295,230 @@ public void deleteConnection(String name) { configurationsUpdated = true; } + public void updateConfiguration(ConnectionConfiguration config) { + Log.d(TAG, "updateConfig: " + config); + configDatabase.putConfiguration(config); + configurationsUpdated = true; + } + + public void updateConnectionStrategy(ConnectionConfiguration config, int strategy) { + config.connectionRetryStrategy = strategy; + updateConfiguration(config); + + synchronized (this) { + if (configurations != null) { + for (int i = 0; i < configurations.length; i++) { + if (config.address != null + && config.address.equalsIgnoreCase(configurations[i].address)) { + configurations[i] = config; + break; + } + } + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public boolean retryConnection(String nodeId, boolean force) { + ConnectionConfiguration config = getConfigurationByNodeId(nodeId); + if (config == null) config = getConfigurationByPeerNodeId(nodeId); + if (config == null) config = getConfigurationByName(nodeId); + if (config == null) { + Log.w(TAG, "retryConnection: no config for node " + nodeId); + return false; + } + Log.d(TAG, "retryConnection: node=" + nodeId + " config=" + config.name + + " force=" + force + " connected=" + config.connected); + + if (config.type == TYPE_CLOUD) { + return true; + } + + try { + if (force && config.connected) { + disableConnection(config.name); + } + enableConnection(config.name); + } catch (Exception e) { + Log.w(TAG, "retryConnection: failed for node " + nodeId, e); + return false; + } + return true; + + } + + public boolean cancelNodeMigration(ConnectionConfiguration config) { + if (config == null) { + Log.w(TAG, "cancelNodeMigration: null config"); + return false; + } + String nodeId = config.nodeId; + Log.d(TAG, "cancelNodeMigration: node=" + nodeId + " config=" + config.name); + + if (migrationController.isMigrationActive()) { + migrationController.markCancelled(); + migrationController.onMigrationAborted(); + } + if (nodeId != null) { + migrationController.markNodeMigrationCompleted(nodeId); + migrationController.resumeNode(nodeId); + + migrationTracker.clearMigrationInfo(nodeId); + } + + if (config.migrating) { + ConnectionConfiguration cleared = new ConnectionConfiguration( + config.name, config.address, config.type, config.role, config.enabled, + config.connected, config.peerNodeId, config.btlePriority, + config.nodeId, config.packageName, config.connectionRetryStrategy, + config.allowedConfigPackages, false, + config.dataItemSyncEnabled, config.connectionRestrictions, + config.removeConnectionWhenBondRemovedByUser, + config.connectionDelayFilters, + config.maxSupportedRemoteAndroidSdkVersion, config.runtimeType); + updateConfiguration(cleared); + synchronized (this) { + if (configurations != null) { + for (int i = 0; i < configurations.length; i++) { + if (config.address != null + && config.address.equalsIgnoreCase(configurations[i].address)) { + configurations[i] = cleared; + break; + } + } + } + } + } + return true; + } + + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void createConnection(ConnectionConfiguration config) { - if (config.nodeId == null) config.nodeId = getLocalNodeId(); - Log.d(TAG, "putConfig[nyp]: " + config); + + if (configurationsUpdated) { + getConfigurations(); + } + + ConnectionConfiguration existing = getConfigurationByAddress(config.address); + if (existing != null) { + Log.d(TAG, "Config already exists for address " + config.address + ", updating"); + + if (config.peerNodeId == null && existing.peerNodeId != null) + config.peerNodeId = existing.peerNodeId; + + if (!config.connected && existing.connected) + config.connected = existing.connected; + + if (config.nodeId == null && existing.nodeId != null) + config.nodeId = existing.nodeId; + + configDatabase.putConfiguration(config); + configurationsUpdated = true; + + if (configurations != null) { + for (int i = 0; i < configurations.length; i++) { + if (config.address != null + && config.address.equalsIgnoreCase(configurations[i].address)) { + configurations[i] = config; + break; + } + } + } + +// if (config.name != null && !config.name.isEmpty() && !"null".equals(config.name) +// && !config.name.equals(config.address)) { +// existing.name = config.name; +// } +// existing.enabled = config.enabled; +// if (existing.nodeId == null && config.nodeId != null) { +// existing.nodeId = config.nodeId; +// } +// configDatabase.putConfiguration(existing); +// configurationsUpdated = true; + return; + } + + Log.d(TAG, "putConfig[new]: " + config); configDatabase.putConfiguration(config); configurationsUpdated = true; + + if (configurations != null) { + ConnectionConfiguration[] newConfigs = new ConnectionConfiguration[configurations.length + 1]; + System.arraycopy(configurations, 0, newConfigs, 0, configurations.length); + newConfigs[configurations.length] = config; + configurations = newConfigs; + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void handleBle(ConnectionConfiguration config, boolean enabled) { + if (bleManager == null) { + bleManager = new BleManager(context, this); + } + if (enabled) { + bleManager.enable(config); + } else { + bleManager.disable(config); + } + } + + private void handleNetwork(ConnectionConfiguration config, boolean enabled) { + if (networkManager == null) { + networkManager = new NetworkConnectionManager(context, this); + } + + if (enabled) { + networkManager.addConfig(config); + } else { + networkManager.removeConfig(config); + } + } + + private void handleLegacy(ConnectionConfiguration config, boolean enabled) { + try { + if (config.role == ROLE_CLIENT) { + if (enabled) { + networkHandlerLock.await(); + networkHandler.post(() -> { + if (bluetoothClient == null) { + Log.d(TAG, "Initializing BluetoothClient"); + bluetoothClient = new BluetoothClient(context, this); + } + bluetoothClient.addConfig(config); + }); + } else { + networkHandlerLock.await(); + networkHandler.post(() -> { + if (bluetoothClient != null) { + bluetoothClient.removeConfig(config); + } + }); + } + } else if (config.role == ROLE_SERVER) { + networkHandlerLock.await(); + networkHandler.post(() -> { + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + + if (adapter == null) { + Log.w(TAG, "handlelegacy: no bluetooth adapter, cannot serve " + config.name); + return; + } + + if (enabled) { + if (bluetoothServer == null) { + Log.d(TAG, "handleLegacy: Initializing BluetoothServer"); + bluetoothServer = new BluetoothServer(context, this, adapter); + } + bluetoothServer.addConfiguration(config); + } else if (bluetoothServer != null) { + bluetoothServer.removeConfiguration(config); + } + }); + } + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while handling Bluetooth", e); + } } public int deleteDataItems(Uri uri, String packageName) { @@ -545,14 +1529,6 @@ public int deleteDataItems(Uri uri, String packageName) { return records.size(); } - public void sendMessageReceived(String packageName, MessageEventParcelable messageEvent) { - Log.d(TAG, "onMessageReceived: " + messageEvent); - Intent intent = new Intent("com.google.android.gms.wearable.MESSAGE_RECEIVED"); - intent.setPackage(packageName); - intent.setData(Uri.parse("wear://" + getLocalNodeId() + "/" + messageEvent.getPath())); - invokeListeners(intent, listener -> listener.onMessageReceived(messageEvent)); - } - public DataItemRecord getDataItemByUri(Uri uri, String packageName) { Cursor cursor = nodeDatabase.getDataItemsByHostAndPath(packageName, PackageUtils.firstSignatureDigest(context, packageName), fixHost(uri.getHost(), true), uri.getPath()); DataItemRecord record = null; @@ -575,58 +1551,501 @@ private IWearableListener getListener(String packageName, String action, Uri uri } private void closeConnection(String nodeId) { - WearableConnection connection = activeConnections.get(nodeId); - try { - connection.close(); - } catch (IOException e1) { - Log.w(TAG, e1); + WearableConnection connection = activeConnections.remove(nodeId); + nodeRestrictions.remove(nodeId); + if (connection != null) { + try { + connection.close(); + } catch (IOException e1) { + Log.w(TAG, "Error closing connection", e1); + } } - if (connection == sct.getWearableConnection()) { + + + if (sct != null && connection == sct.getWearableConnection()) { sct.close(); sct = null; } + activeConnections.remove(nodeId); + + String name = "Wear device"; for (ConnectionConfiguration config : getConfigurations()) { if (nodeId.equals(config.nodeId) || nodeId.equals(config.peerNodeId)) { config.connected = false; + name = config.name; } } - onPeerDisconnected(new NodeParcelable(nodeId, "Wear device")); - Log.d(TAG, "Closed connection to " + nodeId + " on error"); + + if (connection != null) { + onPeerDisconnected(new NodeParcelable(nodeId, name)); + Log.d(TAG, "Closed connection to " + nodeId + " on error"); + } } - public int sendMessage(String packageName, String targetNodeId, String path, byte[] data) { - if (activeConnections.containsKey(targetNodeId)) { - WearableConnection connection = activeConnections.get(targetNodeId); - RpcHelper.RpcConnectionState state = rpcHelper.useConnectionState(packageName, targetNodeId, path); + public void sendControlMessage(String nodeId, int type) { + WearableConnection connection = activeConnections.get(nodeId); + if (connection == null) { + Log.w(TAG, "sendControlMessage: no connection to=" + nodeId + " type=" + type); + return; + } + + try { + connection.writeMessage( + new RootMessage.Builder().controlMessage( + new ControlMessage.Builder().type(type).build() + ).build() + ); + Log.d(TAG, "sendControlMessage: type=" + type + " nodeId=" + nodeId); + } catch (IOException e) { + Log.w(TAG, "sendControlMessage: failed for " + nodeId + " type=" + type + ": " + e.getMessage()); + } + } + + public void onMigrationSucceeded(String newNodeId, ConnectionConfiguration migratingConfig) { + Log.i(TAG, "onMigrationSucceeded: newNode=" + newNodeId); + + migrationTracker.setMigrationComplete(newNodeId); + migrationController.onMigrationSucceeded(); + migratingConfig.migrating = false; + updateConfiguration(migratingConfig); + + try { + String localNodeId = getLocalNodeId(); + String gmsSignature = "38918a453d07199354f8b19af05ec6562ced5788"; // hardcoded in gms + DataItemInternal marker = new DataItemInternal(localNodeId, "/setup/sync_marker/" + newNodeId); + marker.data = new byte[0]; + DataItemRecord markerRecord = putDataItem( + "com.google.android.gms", gmsSignature, localNodeId, marker); + syncRecordToAll(markerRecord); + Log.d(TAG, "onMigrationSucceeded: sync marker written for node=" + newNodeId); + } catch (Exception e) { + Log.w(TAG, "onMigrationSucceeded: failed to write sync marker", e); + } + + + DataTransport dt = peerTransports.get(newNodeId); + if (dt != null) { + dt.sendSyncStart(); + } else { + // fallback, in ideal we dont want this to call + Log.w(TAG, "onMigrationSucceeded: no DataTransport for " + newNodeId); +// syncToPeer(newNodeId, getLocalNodeId(), -1L); + } + + Log.d(TAG, "Migration to " + newNodeId + " complete"); + } + + public void onMigrationFailed(String newNodeId, boolean sendMsg) { + Log.w(TAG, "onMigrationFailed: node=" + newNodeId + " sendMsg=" + sendMsg); + + if (sendMsg && newNodeId != null) { + sendControlMessage(newNodeId, NodeMigrationController.CTRL_MIGRATION_FAILED); + try { - connection.writeMessage(new RootMessage.Builder().rpcRequest(new Request.Builder() - .targetNodeId(targetNodeId) - .path(path) - .rawData(ByteString.of(data)) - .packageName(packageName) - .signatureDigest(PackageUtils.firstSignatureDigest(context, packageName)) - .sourceNodeId(getLocalNodeId()) - .generation(state.generation) - .requestId(state.lastRequestId) - .build()).build()); - } catch (IOException e) { - Log.w(TAG, "Error while writing, closing link", e); - closeConnection(targetNodeId); + Thread.sleep(100); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + + if (newNodeId != null) { + migrationTracker.clearMigrationInfo(newNodeId); + nodeDatabase.deleteAllItemsBySourceNode(newNodeId); + } + + String oldNodeId = migrationController.getMigratingFromNodeId(); + migrationController.onMigrationAborted(); + + if (newNodeId != null) { + closeConnection(newNodeId); + } + + if (oldNodeId != null) { + migrationController.resumeNode(oldNodeId); + sendControlMessage(oldNodeId, NodeMigrationController.CTRL_RESUME_SYNC); + Log.d(TAG, "onMigrationFailed: cleared suspension for old node=" + oldNodeId); + } + + Log.d(TAG, "Migration failed: newNode=" + newNodeId + " oldNode=" + oldNodeId); + } + + public void triggerResync(String nodeId) { + Log.d(TAG, "triggerResync: " + nodeId); + DataTransport dt = peerTransports.get(nodeId); + if (dt != null) { + dt.sendSyncStart(); +// } else { +// syncToPeer(nodeId, getLocalNodeId(), -1L); + } + } + + public void unregisterPeerTransport(String peerNodeId) { + peerTransports.remove(peerNodeId); + peerWriters.remove(peerNodeId); + assetManager.removeWriter(peerNodeId); + assetFetcher.resetTracking(); + Log.d(TAG, "unregisterPeerTransport: " + peerNodeId); + } + + public int sendMessage(String packageName, String targetNodeId, String path, byte[] data, MessageOptions options) { + return sendInternal(packageName, targetNodeId, path, data, options, false); + } + + public int sendRequest(String packageName, String targetNodeId, String path, byte[] data, MessageOptions options) { + return sendInternal(packageName, targetNodeId, path, data, options, true); + } + + public int sendInternal(String packageName, String targetNodeId, String path, byte[] data, MessageOptions options, boolean requiresResponse) { + targetNodeId = resolveToWearableNodeId(targetNodeId); + + if (activeConnections.containsKey(targetNodeId)) { + ConnectionConfiguration cfg = getConfigurationByPeerNodeId(targetNodeId); + if (cfg == null) + cfg = getConfigurationByNodeId(targetNodeId); + + if (cfg != null + && cfg.packageName != null + && packageName != null + && !packageName.equals(cfg.packageName)) { + Log.w(TAG, "sendMessage: refusing delivery of " + path + + " to node " + targetNodeId + " - owned by " + cfg.packageName + + ", caller is " + packageName); return -1; } - return (state.generation + 527) * 31 + state.lastRequestId; + + WearableConnection connection = activeConnections.get(targetNodeId); + return writeToConnection(packageName, targetNodeId, connection, path, data, options, requiresResponse); + } + + if ("*".equals(targetNodeId)) { + int lastResult = -1; + for (Map.Entry e : activeConnections.entrySet()) { + boolean nearby = false; + for (Node n : connectedNodes) { + if (n.getId().equals(e.getKey()) && n.isNearby()) { + nearby = true; + break; + } + } + if (nearby) { + lastResult = writeToConnection(packageName, e.getKey(), e.getValue(), path, data, options, requiresResponse); + } + } + return lastResult; } - Log.d(TAG, targetNodeId + " seems not reachable"); + + Log.d(TAG, targetNodeId + " not reachable"); return -1; } - public void stop() { + private int writeToConnection(String packageName, String nodeId, WearableConnection connection, + String path, byte[] data, MessageOptions options, boolean requiresResponse) { + PendingRpcRequest pending = consumePendingRpcRequest(nodeId, path); + Log.d(TAG, "writeToConnection: node=" + nodeId + " path=" + path + + " package=" + packageName + " pending=" + (pending != null)); + + if (pending != null) { + if (TextUtils.isEmpty(pending.packageName) || TextUtils.isEmpty(pending.signatureDigest)) { + Log.w(TAG, "writeToConnection: dropping response for " + path + ", request had empty package/signatureDigest"); + return -1; + } + return rpcTransport.sendResponse(pending.packageName, pending.signatureDigest, + nodeId, path, data, pending.reqId); + } + return writeToConnectionWithoutPending(packageName, nodeId, connection, path, data, options, requiresResponse); + } + + private int writeToConnectionWithoutPending(String packageName, String nodeId, WearableConnection connection, + String path, byte[] data, MessageOptions options, boolean requiresResponse) { + String digest = PackageUtils.firstSignatureDigest(context, packageName); + if (TextUtils.isEmpty(packageName) || TextUtils.isEmpty(digest)) { + Log.w(TAG, "writeToConnectionWithoutPending: no signature digest for " + packageName + + ", not sending " + path); + return -1; + } + return requiresResponse + ? rpcTransport.sendRequest(packageName, digest, nodeId, path, data, options) + : rpcTransport.sendMessage(packageName, digest, nodeId, path, data, options); + } + + public void sendRpcResponse(PendingRpcRequest p, byte[] data) { + if (TextUtils.isEmpty(p.packageName) || TextUtils.isEmpty(p.signatureDigest)) { + Log.w(TAG, "sendRpcResponse: missing package/signatureDigest for " + p.path); + return; + } try { - this.networkHandlerLock.await(); - this.networkHandler.getLooper().quit(); - } catch (InterruptedException e) { - Log.w(TAG, e); + p.connection.writeMessage(RpcMessageTransport.wrap(rpcTransport.buildRequest( + p.packageName, p.signatureDigest, p.peerNodeId, p.path, data, + null, false, p.reqId, null))); + Log.d(TAG, "sendRpcResponse: path=" + p.path + " senderRequestId=" + p.reqId + + " bytes=" + (data != null ? data.length : 0)); + } catch (IOException e) { + Log.w(TAG, "sendRpcResponse: write failed for " + p.path, e); + } + } + + public boolean dispatchRpcRequest( + Intent intent, + MessageEventParcelable event, + PendingRpcRequest pending, + Runnable onDeclined) { + + final boolean[] completed = {false}; + final Object lock = new Object(); + + final IRpcResponseCallback responseCallback = + new IRpcResponseCallback.Stub() { + @Override + public void onResponse(boolean success, byte[] data) { + synchronized (lock) { + if (completed[0]) { + return; + } + completed[0] = true; + } + + Log.d( + TAG, + "dispatchRpcRequest: response success=" + + success + + " path=" + pending.path + + " bytes=" + + (data != null ? data.length : 0) + ); + + if (success) { + sendRpcResponse(pending, data); + } else if (onDeclined != null) { + networkHandler.post(onDeclined); + } + } + }; + + /* + * No resolveService(). + * No bindService(). + * + * The listener is already registered in `listeners`. + */ + boolean delivered = invokeRequestListeners( + intent, + event, + responseCallback + ); + + if (!delivered) { + Log.d( + TAG, + "dispatchRpcRequest: no matching REQUEST_RECEIVED listener: " + + intent + ); + } + + return delivered; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + public void stop() { + if (channelManager != null) { + channelManager.stop(); + channelManager = null; + } + if (networkHandlerThread != null) { + networkHandlerThread.quitSafely(); + networkHandlerThread = null; + } + + if (bluetoothClient != null) { + bluetoothClient.close(); + bluetoothClient = null; + } + if (bluetoothServer != null) { + bluetoothServer.close(); + bluetoothServer = null; + } + if (sct != null) { + sct.close(); + sct = null; + } + } + + public String resolveToWearableNodeId(String nodeId) { + if (nodeId == null || activeConnections.containsKey(nodeId)) + return nodeId; + + for (ConnectionConfiguration c : getConfigurations()) { + if (nodeId.equals(c.nodeId) && c.peerNodeId != null + && activeConnections.containsKey(c.peerNodeId)) { + Log.d(TAG, "resolveToWearableNodeId: " + nodeId + " to " + c.peerNodeId); + return c.peerNodeId; + } + } + + return nodeId; + } + + public void storePendingRpcRequest(PendingRpcRequest req) { + pendingRpcRequests.put(req.peerNodeId + ":" + req.path, req); + Log.d(TAG, "storePendingRpcRequest: requestId=" + req.reqId + + " path=" + req.path + " peer=" + req.peerNodeId); + } + + public PendingRpcRequest consumePendingRpcRequest(String targetNodeId, String path) { + return pendingRpcRequests.remove(targetNodeId + ":" + path); + } + + public void clearPendingRpcRequestsForPeer(String peerNodeId) { + Iterator> it = + pendingRpcRequests.entrySet().iterator(); + + while (it.hasNext()) { + Map.Entry e = it.next(); + + if (e.getKey().startsWith(peerNodeId + ":")) { + it.remove(); + } + } + } + + public NodeDatabaseHelper getNodeDatabase() { + return nodeDatabase; + } + + public ClockworkNodePreferences getClockworkNodePreferences() { + return clockworkNodePreferences; + } + + public NodeMigrationController getMigrationController() { + return migrationController; + } + + public RpcHelper getRpcHelper() { + return rpcHelper; + } + + public RpcMessageTransport getRpcTransport() { return rpcTransport; } + + + interface ListenerInvoker { + void invoke(IWearableListener listener) throws RemoteException; + } + + public static final class PendingRpcRequest { + public final int reqId; + public final int gen; + public final String path; + public final String peerNodeId; + public final String packageName; + public final String signatureDigest; + public final WearableConnection connection; + + public PendingRpcRequest(int requestId, int generation, String path, + String peerNodeId, String packageName, String signatureDigest, + WearableConnection connection) { + this.reqId = requestId; + this.gen = generation; + this.path = path; + this.peerNodeId = peerNodeId; + this.packageName = packageName; + this.signatureDigest = signatureDigest; + this.connection = connection; + } + } + + private class WearableChannelCallbacks implements ChannelCallbacks { + @Override + public void onChannelOpened(ChannelToken token, String path) { + Log.d(TAG, "onChannelOpened: " + token + ", path=" + path); + ChannelParcelable channel = token.toParcelable(path); + + Intent intent = new Intent("com.google.android.gms.wearable.CHANNEL_EVENT"); + if (token.thisNodeWasOpener) + intent.setPackage(token.appKey.packageName); + intent.setData(new Uri.Builder().scheme("wear").authority("").path(path).build()); + + invokeListeners(intent, listener -> { + try { + ChannelEventParcelable event = new ChannelEventParcelable( + channel, ChannelEventParcelable.EVENT_TYPE_CHANNEL_OPENED, + 0, 0); + listener.onChannelEvent(event); + } catch (Exception e) { + Log.w(TAG, "Error notifying listener of channel opened", e); + } + }); + } + + @Override + public void onChannelClosed(ChannelToken token, String path, int closeReason, int errorCode) { + Log.d(TAG, "onChannelClosed: " + token + ", reason=" + closeReason); + + DataTransport dt = peerTransports.get(token.nodeId); + if (dt != null) + networkHandler.post(() -> dt.onPeerChannelClosed()); + + ChannelParcelable channel = token.toParcelable(path); + + Intent intent = new Intent("com.google.android.gms.wearable.CHANNEL_EVENT"); + if (token.thisNodeWasOpener) + intent.setPackage(token.appKey.packageName); + intent.setData(new Uri.Builder().scheme("wear").authority("").path(path).build()); + + invokeListeners(intent, listener -> { + try { + ChannelEventParcelable event = new ChannelEventParcelable( + channel, ChannelEventParcelable.EVENT_TYPE_CHANNEL_CLOSED, + closeReason, errorCode); + listener.onChannelEvent(event); + } catch (Exception e) { + Log.w(TAG, "Error notifying listener of channel closed", e); + } + }); + } + + @Override + public void onChannelInputClosed(ChannelToken token, String path, int closeReason, int errorCode) { + Log.d(TAG, "onChannelInputClosed: " + token); + ChannelParcelable channel = token.toParcelable(path); + + Intent intent = new Intent("com.google.android.gms.wearable.CHANNEL_EVENT"); + if (token.thisNodeWasOpener) + intent.setPackage(token.appKey.packageName); + intent.setData(new Uri.Builder().scheme("wear").authority("").path(path).build()); + + invokeListeners(intent, listener -> { + try { + ChannelEventParcelable event = new ChannelEventParcelable( + channel, ChannelEventParcelable.EVENT_TYPE_INPUT_CLOSED, + closeReason, errorCode); + listener.onChannelEvent(event); + } catch (Exception e) { + Log.w(TAG, "Error notifying listener of input closed", e); + } + }); + + } + + @Override + public void onChannelOutputClosed(ChannelToken token, String path, int closeReason, int errorCode) { + Log.d(TAG, "onChannelOutputClosed: " + token); + ChannelParcelable channel = token.toParcelable(path); + + Intent intent = new Intent("com.google.android.gms.wearable.CHANNEL_EVENT"); + if (token.thisNodeWasOpener) + intent.setPackage(token.appKey.packageName); + intent.setData(new Uri.Builder().scheme("wear").authority("").path(path).build()); + + invokeListeners(intent, listener -> { + try { + ChannelEventParcelable event = new ChannelEventParcelable( + channel, ChannelEventParcelable.EVENT_TYPE_OUTPUT_CLOSED, closeReason, errorCode); + listener.onChannelEvent(event); + } catch (Exception e) { + Log.w(TAG, "Error notifying listener of output closed", e); + } + }); + } } @@ -639,4 +2058,5 @@ private ListenerInfo(IWearableListener listener, IntentFilter[] filters) { this.filters = filters; } } + } diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableLogStore.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableLogStore.java new file mode 100644 index 0000000000..8f4182f3b3 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableLogStore.java @@ -0,0 +1,56 @@ +package org.microg.gms.wearable; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class WearableLogStore { + public static final WearableLogStore INSTANCE = new WearableLogStore(); + + private static final int MAX_NAMES = 256; + + private final Map counters = bounded(); + private final Map timers = bounded(); + private long eventCount; + private long eventBytes; + + private WearableLogStore() {} + + private static Map bounded() { + return new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > MAX_NAMES; + } + }; + } + + public synchronized void counter(String name, long value, boolean increment) { + Long current = counters.get(name); + counters.put(name, increment && current != null ? current + value : value); + } + + public synchronized void event(int dataLength) { + eventCount++; + eventBytes += dataLength; + } + + public synchronized void timer(String name, long timestamp) { + timers.put(name, timestamp); + } + + public synchronized void clear() { + counters.clear(); + timers.clear(); + eventCount = 0; + eventBytes = 0; + } + + public synchronized Map getCounters() { + return new HashMap<>(counters); + } + + public synchronized long getEventCount() { + return eventCount; + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableReader.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableReader.java new file mode 100644 index 0000000000..7d6936e824 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableReader.java @@ -0,0 +1,151 @@ +package org.microg.gms.wearable; + +import android.util.Log; + +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class WearableReader { + private static final String TAG = "GmsWearReader"; + + private final String nodeId; + + private final WearableConnection source; + private final WearableConnection listenerView; + private final WearableConnection.Listener listener; + + private final CountDownLatch finishedLatch = new CountDownLatch(1); + private final AtomicBoolean closed = new AtomicBoolean(false); + + private volatile Thread thread; + private final Runnable onTrafficReceived; + + private ExecutorService dataExecutor; + private ExecutorService controlExecutor; + + public WearableReader(String nodeId, WearableConnection source, + WearableConnection listenerView, WearableConnection.Listener listener) { + this(nodeId, source, listenerView, listener, null); + } + + public WearableReader(String nodeId, WearableConnection source, + WearableConnection listenerView, WearableConnection.Listener listener, + Runnable onTrafficReceived) { + this.nodeId = nodeId; + this.source = source; + this.listenerView = listenerView; + this.listener = listener; + this.onTrafficReceived = onTrafficReceived; + } + + public void start() { + thread = new Thread(this::loop, "WearReader-" + nodeId); + thread.start(); + Log.d(TAG, "Reader started for node "+ nodeId); + } + + public void close() { + if (closed.compareAndSet(false, true)) { + Log.d(TAG, "Closing reader for node " + nodeId); + Thread t = thread; + if (t != null) t.interrupt(); + try { + source.close(); + } catch (IOException ignore) {} + } + } + + public boolean awaitFinished(long timeout, TimeUnit unit) throws InterruptedException { + return finishedLatch.await(timeout, unit); + } + + public void awaitFinished() { + boolean interrupted = false; + while (true) { + try { + finishedLatch.await(); + break; + } catch (InterruptedException e){ + interrupted = true; + } + } + if (interrupted) Thread.currentThread().interrupt(); + } + + public boolean isClosed() { + return closed.get(); + } + + private void loop() { + dataExecutor = Executors.newSingleThreadExecutor(r -> + new Thread(r, "WearReaderData-" + nodeId)); + controlExecutor = Executors.newSingleThreadExecutor(r -> + new Thread(r, "WearReaderCtrl-" + nodeId)); + try { + listener.onConnected(listenerView); + + while (!Thread.currentThread().isInterrupted()) { + RootMessage message; + try { + message = source.readMessage(); + } catch (IOException e) { + if (!closed.get()) { + Log.w(TAG, "Read error from node " + nodeId + ": " + e.getMessage()); + } + break; + } + + if (message == null) break; + + if (message.heartbeat != null) + continue; + + if (onTrafficReceived != null) { + try { + onTrafficReceived.run(); + } catch (Exception e) { + Log.w(TAG, "onTrafficReceived hook threw", e); + } + } + + final RootMessage msg = message; + ExecutorService lane = isDataLayerMessage(msg) ? dataExecutor : controlExecutor; + try { + lane.execute(() -> { + try { + listener.onMessage(listenerView, msg); + } catch (Exception e) { + Log.e(TAG, "Error dispatching message from node " + nodeId, e); + } + }); + } catch (RejectedExecutionException ignore) {} + } + } catch (Exception e) { + if (!closed.get()) { + Log.e(TAG, "Unexpected reader error for node" + nodeId, e); + } + } finally { + Log.d(TAG, "Reader finished for node " + nodeId); + if (controlExecutor != null) controlExecutor.shutdownNow(); + if (dataExecutor != null) dataExecutor.shutdownNow(); + finishedLatch.countDown(); + } + } + + private static boolean isDataLayerMessage(RootMessage m) { + return m.setDataItem != null + || m.setAsset != null + || m.ackAsset != null + || m.fetchAsset != null + || m.filePiece != null + || m.syncStart != null; + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java index 5083bd4a05..a5cddc9f29 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java @@ -16,8 +16,14 @@ package org.microg.gms.wearable; +import android.Manifest; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.os.Build; import android.os.RemoteException; +import androidx.annotation.RequiresPermission; + import com.google.android.gms.common.Feature; import com.google.android.gms.common.api.CommonStatusCodes; import com.google.android.gms.common.internal.ConnectionInfo; @@ -30,6 +36,8 @@ public class WearableService extends BaseService { + private WearableImpl wearable; + public static final Feature[] FEATURES = new Feature[]{ new Feature("app_client", 4L), new Feature("carrier_auth", 1L), @@ -66,7 +74,7 @@ public class WearableService extends BaseService { new Feature("wear_notify_channel_flushed", 1L) }; - private WearableImpl wearable; + private static final String CHANNEL_ID = "wearable_service"; public WearableService() { super("GmsWearSvc", GmsService.WEAR); @@ -75,11 +83,26 @@ public WearableService() { @Override public void onCreate() { super.onCreate(); + createNotificationChannel(); ConfigurationDatabaseHelper configurationDatabaseHelper = new ConfigurationDatabaseHelper(getApplicationContext()); NodeDatabaseHelper nodeDatabaseHelper = new NodeDatabaseHelper(getApplicationContext()); wearable = new WearableImpl(getApplicationContext(), nodeDatabaseHelper, configurationDatabaseHelper); } + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "Wearable Connection", + NotificationManager.IMPORTANCE_LOW + ); + channel.setShowBadge(false); + NotificationManager nm = getSystemService(NotificationManager.class); + nm.createNotificationChannel(channel); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) @Override public void onDestroy() { super.onDestroy(); diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java index 1fb2c589eb..070a12b37f 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java @@ -16,24 +16,73 @@ package org.microg.gms.wearable; +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; import android.net.Uri; +import android.os.Binder; import android.os.Handler; +import android.os.Looper; import android.os.Parcel; import android.os.ParcelFileDescriptor; import android.os.RemoteException; +import android.telephony.TelephonyManager; +import android.text.TextUtils; import android.util.Base64; import android.util.Log; +import androidx.annotation.RequiresPermission; + +import com.google.android.gms.common.api.CommonStatusCodes; import com.google.android.gms.common.api.Status; +import com.google.android.gms.common.data.DataHolder; +import com.google.android.gms.wearable.AppRecommendationsRequest; +import com.google.android.gms.wearable.AppTheme; import com.google.android.gms.wearable.Asset; import com.google.android.gms.wearable.ConnectionConfiguration; +import com.google.android.gms.wearable.ConnectionDelayConfig; +import com.google.android.gms.wearable.MessageOptions; +import com.google.android.gms.wearable.Term; +import com.google.android.gms.wearable.WearableStatusCodes; import com.google.android.gms.wearable.internal.*; +import org.microg.gms.profile.Build; +import org.microg.gms.wearable.channel.ChannelManager; +import org.microg.gms.wearable.channel.ChannelStateMachine; +import org.microg.gms.wearable.channel.ChannelStatusCodes; +import org.microg.gms.wearable.channel.ChannelToken; +import org.microg.gms.wearable.channel.InvalidChannelTokenException; +import org.microg.gms.wearable.channel.OpenChannelCallback; +import org.microg.gms.wearable.network.WearableWifiService; +import org.microg.gms.wearable.proto.AppKey; +import org.microg.gms.wearable.proto.BackupBoolResponse; +import org.microg.gms.wearable.proto.DataSyncTrackingMessage; +import org.microg.gms.wearable.proto.PrivacySettings; +import org.microg.gms.wearable.proto.ProtoDuration; +import org.microg.gms.wearable.proto.AccountConsentRecord; +import org.microg.gms.wearable.proto.BackupErrorResponse; +import org.microg.gms.wearable.proto.EnableBackupRequest; +import org.microg.gms.wearable.proto.EnableBackupSkippedRequest; + +import java.io.File; import java.io.FileNotFoundException; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; public class WearableServiceImpl extends IWearableService.Stub { private static final String TAG = "GmsWearSvcImpl"; @@ -44,6 +93,69 @@ public class WearableServiceImpl extends IWearableService.Stub { private final Handler mainHandler; private final CapabilityManager capabilities; + private final Handler handler = new Handler(Looper.getMainLooper()); + + private final Map restoreStateByNode = new ConcurrentHashMap<>(); + private final Map restoreDataByNode = new ConcurrentHashMap<>(); + private static final String CAPABILITY_BACKUP_SETTINGS = "com.google.android.gms.wearable.companion_backup_settings_wear_app"; + private static final long BACKUP_RPC_TIMEOUT_MS = 5_000L; + private static final long SEND_REQUEST_TIMEOUT_MS = 60_000; + + public static final String DATA_SYNC_PROGRESS_PATH = "DATA_SYNC_PROGRESS"; + private static final long DATA_SYNC_TRACKING_TIMEOUT_MS = 30_000L; + + private static final int WEAR_FEATURE_DISABLED = 4014; + + private static final String CONSENT_PACKAGE = "com.google.android.gms"; + private static final String PATH_PRIVACY_SETTINGS = "/privacy_settings"; + + private static final Object CONSENT_LOCK = new Object(); + + private static final int TERMS_CONTEXT_UNSUPERVISED = 0; + private static final int TERMS_CONTEXT_SUPERVISED = 1; + + private static final int TERM_TOS = 0; + private static final int TERM_LOGGING = 1; + private static final int TERM_CLOUDSYNC = 2; + private static final int TERM_LOCATION = 3; + private static final int TERM_UPDATES = 4; + private static final int TERM_BACKUP = 5; + private static final int OPT_IN_TYPE_BACKUP = 4; + + private static final String PATH_ENABLE_BACKUP = "/backup_settings/enable_backup"; + private static final String PATH_ENABLE_BACKUP_SKIPPED = "/backup_settings/enable_backup_skipped"; + private static final int FRAGMENT_COMPANION_TERMS_OF_SERVICE = 16; + private static final long ENABLE_BACKUP_RPC_TIMEOUT_MS = 15_000L; + + private static final String PREFS_APP_THEMES = "wearable_app_themes"; + + private interface StatusSink { + void onStatus(int statusCode); + } + + private static final class BackupAction { + final String nodeId; + final String accountName; + final boolean enable; + + BackupAction(String nodeId, String accountName, boolean enable) { + this.nodeId = nodeId; + this.accountName = accountName; + this.enable = enable; + } + } + + private interface ConsentOperation { + void run() throws Exception; + } + + private WearFastPairManager fastPairManager; + + private synchronized WearFastPairManager getFastPairManager() { + if (fastPairManager == null) fastPairManager = new WearFastPairManager(context); + return fastPairManager; + } + public WearableServiceImpl(Context context, WearableImpl wearable, String packageName) { this.context = context; this.wearable = wearable; @@ -52,6 +164,16 @@ public WearableServiceImpl(Context context, WearableImpl wearable, String packag this.mainHandler = new Handler(context.getMainLooper()); } + private AppKey getAppKey() { + return wearable.getAppKey(packageName); + } + + private ChannelManager getChannelManager() throws RemoteException { + ChannelManager cm = wearable.getChannelManager(); + if (cm == null) throw new RemoteException("ChannelManager not yet initialized"); + return cm; + } + private void postMain(IWearableCallbacks callbacks, RemoteExceptionRunnable runnable) { mainHandler.post(new CallbackRunnable(callbacks) { @Override @@ -74,10 +196,13 @@ public void run(IWearableCallbacks callbacks) throws RemoteException { * Config */ + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) @Override public void putConfig(IWearableCallbacks callbacks, final ConnectionConfiguration config) throws RemoteException { + config.packageName = this.packageName; postMain(callbacks, () -> { wearable.createConnection(config); + wearable.enableConnection(config.name); callbacks.onStatus(Status.SUCCESS); }); } @@ -103,6 +228,7 @@ public void getConfigs(IWearableCallbacks callbacks) throws RemoteException { } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) @Override public void enableConfig(IWearableCallbacks callbacks, final String name) throws RemoteException { Log.d(TAG, "enableConfig: " + name); @@ -112,6 +238,7 @@ public void enableConfig(IWearableCallbacks callbacks, final String name) throws }); } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) @Override public void disableConfig(IWearableCallbacks callbacks, final String name) throws RemoteException { Log.d(TAG, "disableConfig: " + name); @@ -121,6 +248,114 @@ public void disableConfig(IWearableCallbacks callbacks, final String name) throw }); } + @Override + public void updateConnectionStrategy(IWearableCallbacks callbacks, String name, int strategy) throws RemoteException { + Log.d(TAG, "updateConnectionStrategy: name=" + name + ", strategy=" + strategy); + + postMain(callbacks, () -> { + try { + ConnectionConfiguration config = wearable.getConfigurationByName(name); + if (config == null) { + Log.w(TAG, "updateConnectionStrategy: no config found with name: " + name); + callbacks.onStatus(new Status(CommonStatusCodes.ERROR)); + return; + } + wearable.updateConnectionStrategy(config, strategy); + callbacks.onStatus(Status.SUCCESS); + } catch (Exception e) { + Log.e(TAG, "updateConnectionStrategy: exception during processing", e); + callbacks.onStatus(new Status(CommonStatusCodes.ERROR)); + } + }); + } + + @Override + public void getRelatedConfigs(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "getRelatedConfigs"); + postMain(callbacks, () -> { + try { + ConnectionConfiguration[] allConfigs = wearable.getConfigurations(); + List relatedConfigs = new ArrayList<>(); + for (ConnectionConfiguration config : allConfigs) { + if (config.packageName == null || config.packageName.equals(packageName)) { + relatedConfigs.add(config); + } + } + + callbacks.onGetConfigsResponse(new GetConfigsResponse(0, + relatedConfigs.toArray(new ConnectionConfiguration[0]))); + } catch (Exception e) { + Log.e(TAG, "getRelatedConfigs failed", e); + callbacks.onGetConfigsResponse(new GetConfigsResponse(8, new ConnectionConfiguration[0])); + } + }); + + } + + @Override + public void updateConfig(IWearableCallbacks callbacks, ConnectionConfiguration config) throws RemoteException { + Log.d(TAG, "updateConfig: " + config); + + postMain(callbacks, () -> { + try { + if (config == null || config.address == null) { + Log.w(TAG, "updateConfig: invalid config"); + callbacks.onStatus(new Status(CommonStatusCodes.ERROR)); + return; + } + + ConnectionConfiguration existing = wearable.getConfigurationByAddress(config.address); + + if (existing == null) { + Log.w(TAG, "updateConfig: no existing config for address " + config.address); + callbacks.onStatus(new Status(CommonStatusCodes.ERROR)); + return; + } + + ConnectionConfiguration toUpdate = config; + if (existing.dataItemSyncEnabled && !config.dataItemSyncEnabled) { + Log.w(TAG, "updateConfig: disabling dataItemSync not allowed, keeping existing value dataItemSyncEnabled=" + existing.dataItemSyncEnabled); + toUpdate = new ConnectionConfiguration( + config.name, config.address, config.type, config.role, config.enabled, + config.connected, config.peerNodeId, config.btlePriority, + config.nodeId, config.packageName, config.connectionRetryStrategy, + config.allowedConfigPackages, config.migrating, + existing.dataItemSyncEnabled, + config.connectionRestrictions, + config.removeConnectionWhenBondRemovedByUser, + config.connectionDelayFilters, + config.maxSupportedRemoteAndroidSdkVersion, config.runtimeType); + + } + + boolean syncNowEnabled = !existing.dataItemSyncEnabled && toUpdate.dataItemSyncEnabled; + + wearable.updateConfiguration(toUpdate); + + if (syncNowEnabled) { + String peerId = toUpdate.peerNodeId != null ? toUpdate.peerNodeId : existing.peerNodeId; + if (peerId != null) { + DataTransport dt = wearable.getDataTransport(peerId); + if (dt != null) { + Log.d(TAG, "updateConfig: triggering deferred sync for " + peerId); + dt.onDataItemSyncEnabled(); + } + } + } + + callbacks.onStatus(Status.SUCCESS); + + } catch (Exception e) { + Log.e(TAG, "updateConfig: exception during processing", e); + try { + callbacks.onStatus(new Status(CommonStatusCodes.ERROR)); + } catch (RemoteException re) { + Log.w(TAG, "Failed to send error status", re); + } + } + }); + } + /* * DataItems */ @@ -189,13 +424,18 @@ public void run(IWearableCallbacks callbacks) throws RemoteException { @Override public void sendMessage(IWearableCallbacks callbacks, final String targetNodeId, final String path, final byte[] data) throws RemoteException { + sendMessageWithOptions(callbacks, targetNodeId, path, data, new MessageOptions(0)); + } + + @Override + public void sendMessageWithOptions(IWearableCallbacks callbacks, final String targetNodeId, final String path, final byte[] data, MessageOptions options) throws RemoteException { Log.d(TAG, "sendMessage: " + targetNodeId + " / " + path + ": " + (data == null ? null : Base64.encodeToString(data, Base64.NO_WRAP))); this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { @Override public void run(IWearableCallbacks callbacks) throws RemoteException { SendMessageResponse sendMessageResponse = new SendMessageResponse(); try { - sendMessageResponse.requestId = wearable.sendMessage(packageName, targetNodeId, path, data); + sendMessageResponse.requestId = wearable.sendMessage(packageName, targetNodeId, path, data, options); if (sendMessageResponse.requestId == -1) { sendMessageResponse.statusCode = 4000; } @@ -213,21 +453,156 @@ public void run(IWearableCallbacks callbacks) throws RemoteException { }); } + @Override + public void sendRequest(IWearableCallbacks callbacks, final String targetNodeId, final String path, final byte[] data) throws RemoteException { + sendRequestWithOptions(callbacks, targetNodeId, path, data, new MessageOptions(0)); + } + + @Override + public void sendRequestWithOptions(IWearableCallbacks callbacks, final String targetNodeId, final String path, final byte[] data, MessageOptions options) throws RemoteException { + Log.d(TAG, "sendRequest: " + targetNodeId + " / " + path + ": " + (data == null ? null : Base64.encodeToString(data, Base64.NO_WRAP))); + this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { + @Override + public void run(IWearableCallbacks callbacks) throws RemoteException { + final int messageId = wearable.sendRequest(packageName, targetNodeId, path, data, options); + + if (messageId == -1) { + Log.w(TAG, "sendRequest: no route to " + targetNodeId + " for " + path); + mainHandler.post(() -> { + try { + callbacks.onRpcResponse(new RpcResponse(4004, -1, new byte[0])); + } catch (RemoteException e) { e.printStackTrace(); } + }); + return; + } + + final String routedNode = wearable.resolveToWearableNodeId(targetNodeId); + wearable.getRpcHelper().addResponseListener( + routedNode, + messageId, + SEND_REQUEST_TIMEOUT_MS, + responseData -> mainHandler.post(() -> { + try { + callbacks.onRpcResponse(new RpcResponse( + 0, messageId, + responseData != null ? responseData : new byte[0])); + } catch (RemoteException e) { e.printStackTrace(); } + }), + () -> { + Log.w(TAG, "sendRequest timeout: " + path + " node=" + targetNodeId); + mainHandler.post(() -> { + try { + callbacks.onRpcResponse(new RpcResponse(15, -1, new byte[0])); + } catch (RemoteException e) { e.printStackTrace(); } + }); + }); + } + }); + } + + @Override + public void getCompanionPackageForNode(IWearableCallbacks callbacks, String nodeId) throws RemoteException { + Log.d(TAG, "getCompanionPackageForNode: " + nodeId); + + postMain(callbacks, () -> { + try { + if (TextUtils.isEmpty(nodeId)) { + Log.e(TAG, "getCompanionPackageForNode: empty nodeId"); + callbacks.onGetCompanionPackageForNodeResponse( + new GetCompanionPackageForNodeResponse(CommonStatusCodes.ERROR, "")); + return; + } + + if ("cloud".equals(nodeId)) { + Log.d(TAG, "getCompanionPackageForNode: cloud node has no package"); + callbacks.onGetCompanionPackageForNodeResponse( + new GetCompanionPackageForNodeResponse(CommonStatusCodes.ERROR, "")); + return; + } + + ConnectionConfiguration[] configurations = wearable.getConfigurations(); + if (configurations != null) { + for (ConnectionConfiguration config : configurations) { + if (nodeId.equals(config.nodeId) || nodeId.equals(config.peerNodeId)) { + String packageName = config.packageName != null ? config.packageName : ""; + Log.d(TAG, "getCompanionPackageForNode: found package " + packageName + " for node " + nodeId); + callbacks.onGetCompanionPackageForNodeResponse( + new GetCompanionPackageForNodeResponse(CommonStatusCodes.SUCCESS, packageName)); + return; + } + } + } + + Log.w(TAG, "getCompanionPackageForNode: node " + nodeId + " not found"); + callbacks.onGetCompanionPackageForNodeResponse( + new GetCompanionPackageForNodeResponse(CommonStatusCodes.ERROR, "")); + + } catch (Exception e) { + Log.e(TAG, "getCompanionPackageForNode: exception during processing", e); + try { + callbacks.onGetCompanionPackageForNodeResponse( + new GetCompanionPackageForNodeResponse(CommonStatusCodes.INTERNAL_ERROR, "")); + } catch (RemoteException re) { + Log.w(TAG, "Failed to send error response", re); + } + } + }); + } + + @Override + public void setCloudSyncSettingByNode(IWearableCallbacks callbacks, String s, boolean b) throws RemoteException { + Log.d(TAG, "unimplemented Method setCloudSyncSettingByNode"); + + // dummy + postMain(callbacks, () -> { + try { + callbacks.onStatus(Status.SUCCESS); + } catch (Exception e) { + Log.e(TAG, "setCloudSyncSettingByNode: exception during processing", e); + callbacks.onStatus(Status.INTERNAL_ERROR); + } + }); + } + @Override public void getFdForAsset(IWearableCallbacks callbacks, final Asset asset) throws RemoteException { Log.d(TAG, "getFdForAsset " + asset); postMain(callbacks, () -> { // TODO: Access control + ParcelFileDescriptor pfd = null; try { - callbacks.onGetFdForAssetResponse(new GetFdForAssetResponse(0, ParcelFileDescriptor.open(wearable.createAssetFile(asset.getDigest()), ParcelFileDescriptor.MODE_READ_ONLY))); + String digest = asset != null ? asset.getDigest() : null; + if (TextUtils.isEmpty(digest)) { + callbacks.onGetFdForAssetResponse(new GetFdForAssetResponse(4005, null)); + return; + } + + File file = wearable.createAssetFile(digest); + if (!file.isFile() || !file.canRead()) { + callbacks.onGetFdForAssetResponse(new GetFdForAssetResponse(4005, null)); + return; + } + + pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + callbacks.onGetFdForAssetResponse(new GetFdForAssetResponse(0, pfd)); } catch (FileNotFoundException e) { + callbacks.onGetFdForAssetResponse(new GetFdForAssetResponse(4005, null)); + } catch (Exception e) { + Log.e(TAG, "getFdForAsset: exception during processing", e); callbacks.onGetFdForAssetResponse(new GetFdForAssetResponse(8, null)); + } finally { + if (pfd != null) { + try { pfd.close(); } catch (IOException ignored) {} + } } }); } @Override public void optInCloudSync(IWearableCallbacks callbacks, boolean enable) throws RemoteException { + Log.d(TAG, "unimplemented Method optInCloudSync"); + + // dummy callbacks.onStatus(Status.SUCCESS); } @@ -235,202 +610,1799 @@ public void optInCloudSync(IWearableCallbacks callbacks, boolean enable) throws @Deprecated public void getCloudSyncOptInDone(IWearableCallbacks callbacks) throws RemoteException { Log.d(TAG, "unimplemented Method: getCloudSyncOptInDone"); + callbacks.onGetCloudSyncOptInOutDoneResponse(new GetCloudSyncOptInOutDoneResponse(0, false)); } @Override public void setCloudSyncSetting(IWearableCallbacks callbacks, boolean enable) throws RemoteException { Log.d(TAG, "unimplemented Method: setCloudSyncSetting"); + + postMain(callbacks, () -> { + // dummy + callbacks.onStatus(new Status(0)); + }); } @Override public void getCloudSyncSetting(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "unimplemented Method: getCloudSyncSetting"); + // disabled by default callbacks.onGetCloudSyncSettingResponse(new GetCloudSyncSettingResponse(0, false)); } @Override public void getCloudSyncOptInStatus(IWearableCallbacks callbacks) throws RemoteException { Log.d(TAG, "unimplemented Method: getCloudSyncOptInStatus"); + // opt out by default + callbacks.onGetCloudSyncOptInStatusResponse(new GetCloudSyncOptInStatusResponse(0, false, true)); } @Override - public void sendRemoteCommand(IWearableCallbacks callbacks, byte b) throws RemoteException { - Log.d(TAG, "unimplemented Method: sendRemoteCommand: " + b); - } - - @Override - public void getLocalNode(IWearableCallbacks callbacks) throws RemoteException { - postMain(callbacks, () -> { - try { - callbacks.onGetLocalNodeResponse(new GetLocalNodeResponse(0, new NodeParcelable(wearable.getLocalNodeId(), wearable.getLocalNodeId()))); - } catch (Exception e) { - callbacks.onGetLocalNodeResponse(new GetLocalNodeResponse(8, null)); - } - }); - } + public void sendAmsRemoteCommand(IWearableCallbacks callbacks, byte command) throws RemoteException { + Log.d(TAG, "unimplemented Method sendAmsRemoteCommand: " + command); - @Override - public void getConnectedNodes(IWearableCallbacks callbacks) throws RemoteException { postMain(callbacks, () -> { - callbacks.onGetConnectedNodesResponse(new GetConnectedNodesResponse(0, wearable.getConnectedNodesParcelableList())); + // return error, because we dont have AMS handling + callbacks.onStatus(new Status(CommonStatusCodes.INTERNAL_ERROR)); }); } - /* - * Capability - */ - - @Override - public void getConnectedCapability(IWearableCallbacks callbacks, String capability, int nodeFilter) throws RemoteException { - Log.d(TAG, "unimplemented Method: getConnectedCapability " + capability + ", " + nodeFilter); - postMain(callbacks, () -> { - List nodes = new ArrayList<>(); - for (String host : capabilities.getNodesForCapability(capability)) { - nodes.add(new NodeParcelable(host, host)); + private void postConsentOperation(IWearableCallbacks callbacks, String name, ConsentOperation operation) { + wearable.networkHandler.post(() -> { + int code = CommonStatusCodes.SUCCESS; + try { + synchronized (CONSENT_LOCK) { + operation.run(); + } + } catch (Exception e) { + Log.w(TAG, name + " failed", e); + code = CommonStatusCodes.ERROR; + } + try { + callbacks.onStatus(new Status(code)); + } catch (RemoteException e) { + Log.w(TAG, name + ": onStatus failed", e); } - CapabilityInfoParcelable capabilityInfo = new CapabilityInfoParcelable(capability, nodes); - callbacks.onGetCapabilityResponse(new GetCapabilityResponse(0, capabilityInfo)); }); } - @Override - public void getAllCapabilities(IWearableCallbacks callbacks, int nodeFilter) throws RemoteException { - Log.d(TAG, "unimplemented Method: getConnectedCapaibilties: " + nodeFilter); - callbacks.onGetAllCapabilitiesResponse(new GetAllCapabilitiesResponse()); - } - - @Override - public void addLocalCapability(IWearableCallbacks callbacks, String capability) throws RemoteException { - Log.d(TAG, "unimplemented Method: addLocalCapability: " + capability); - this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { + private void postConsentResponse(IWearableCallbacks callbacks, String nodeId) { + wearable.networkHandler.post(new CallbackRunnable(callbacks) { @Override public void run(IWearableCallbacks callbacks) throws RemoteException { - callbacks.onAddLocalCapabilityResponse(new AddLocalCapabilityResponse(capabilities.add(capability))); + ConsentResponse response; + try { + synchronized (CONSENT_LOCK) { + response = toConsentResponse(getPrivacySettings(nodeId)); + } + } catch (Exception e) { + Log.e(TAG, "getConsentStatus exception", e); + response = new ConsentResponse(CommonStatusCodes.ERROR, false, false, false, false, null, null, null); + } + callbacks.onConsentResponse(response); } }); } - @Override - public void removeLocalCapability(IWearableCallbacks callbacks, String capability) throws RemoteException { - Log.d(TAG, "unimplemented Method: removeLocalCapability: " + capability); - this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { - @Override - public void run(IWearableCallbacks callbacks) throws RemoteException { - callbacks.onRemoveLocalCapabilityResponse(new RemoveLocalCapabilityResponse(capabilities.remove(capability))); - } - }); + private static String consentPath(String nodeId) { + return TextUtils.isEmpty(nodeId) ? PATH_PRIVACY_SETTINGS : PATH_PRIVACY_SETTINGS + "/" + nodeId; } - @Override - public void addListener(IWearableCallbacks callbacks, AddListenerRequest request) throws RemoteException { - if (request.listener != null) { - wearable.addListener(packageName, request.listener, request.intentFilters); + private PrivacySettings readPrivacySettings(String nodeId) { + Uri uri = new Uri.Builder().scheme("wear").authority("").path(consentPath(nodeId)).build(); + DataItemRecord record = wearable.getDataItemByUri(uri, CONSENT_PACKAGE); + if (record == null || record.deleted || record.dataItem == null || record.dataItem.data == null) { + Log.d(TAG, "Consent data item does not exist for " + (nodeId == null ? "global" : nodeId)); + return null; + } + try { + return PrivacySettings.ADAPTER.decode(record.dataItem.data); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse consent record from dataitem", e); } - callbacks.onStatus(Status.SUCCESS); } - @Override - public void removeListener(IWearableCallbacks callbacks, RemoveListenerRequest request) throws RemoteException { - wearable.removeListener(request.listener); - callbacks.onStatus(Status.SUCCESS); + private PrivacySettings getPrivacySettings(String nodeId) { + if (!TextUtils.isEmpty(nodeId)) { + PrivacySettings perWatch = readPrivacySettings(nodeId); + if (perWatch != null) return perWatch; + } + return readPrivacySettings(null); } - @Override - public void getStorageInformation(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: getStorageInformation"); + private void writePrivacySettings(PrivacySettings.Builder builder, String nodeId) { + PrivacySettings partial = builder.build(); + builder.loggingConsent(Boolean.TRUE.equals(partial.loggingConsent)); + builder.cloudSyncConsent(Boolean.TRUE.equals(partial.cloudSyncConsent)); + builder.locationConsent(Boolean.TRUE.equals(partial.locationConsent)); + + long now = System.currentTimeMillis(); + builder.lastUpdateRequested(new ProtoDuration.Builder() + .seconds(now / 1000) + .nanos((int) ((now % 1000) * 1_000_000)) + .build()); + if (!TextUtils.isEmpty(nodeId)) builder.nodeId(nodeId); + putPrivacySettings(builder.build(), nodeId); } - - @Override - public void clearStorage(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: clearStorage"); + private void putPrivacySettings(PrivacySettings settings, String nodeId) { + Log.d(TAG, "Saving wearable consent to record " + consentPath(nodeId)); + wearable.putData(PutDataRequest.create(consentPath(nodeId)) + .setData(PrivacySettings.ADAPTER.encode(settings)), CONSENT_PACKAGE); } - @Override - public void endCall(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: endCall"); + private static ConsentResponse toConsentResponse(PrivacySettings settings) { + if (settings == null) { + return new ConsentResponse(0, false, false, false, false, null, null, null); + } + Long lastUpdate = null; + if (settings.lastUpdateRequested != null) { + long seconds = settings.lastUpdateRequested.seconds != null ? settings.lastUpdateRequested.seconds : 0L; + int nanos = settings.lastUpdateRequested.nanos != null ? settings.lastUpdateRequested.nanos : 0; + lastUpdate = seconds * 1000L + nanos / 1_000_000; + } + List accounts = null; + if (!settings.accountConsents.isEmpty()) { + accounts = new ArrayList<>(); + for (AccountConsentRecord record : settings.accountConsents) { + accounts.add(new AccountConsentRecordParcelable( + record.accountName, Boolean.TRUE.equals(record.consentGranted))); + } + } + return new ConsentResponse( + 0, + true, + Boolean.TRUE.equals(settings.loggingConsent), + Boolean.TRUE.equals(settings.cloudSyncConsent), + Boolean.TRUE.equals(settings.locationConsent), + accounts, + TextUtils.isEmpty(settings.nodeId) ? null : settings.nodeId, + lastUpdate); } - @Override - public void acceptRingingCall(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: acceptRingingCall"); + private static PrivacySettings.Builder builderFrom(PrivacySettings base) { + return base != null ? base.newBuilder() : new PrivacySettings.Builder(); } - @Override - public void silenceRinger(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: silenceRinger"); + private static boolean isOptInTermType(int termType) { + return termType == TERM_LOGGING || termType == TERM_CLOUDSYNC || termType == TERM_LOCATION; } - /* - * Apple Notification Center Service - */ - - @Override - public void injectAncsNotificationForTesting(IWearableCallbacks callbacks, AncsNotificationParcelable notification) throws RemoteException { - Log.d(TAG, "unimplemented Method: injectAncsNotificationForTesting: " + notification); + private static void setOptIn(PrivacySettings.Builder builder, int termType, boolean value) { + switch (termType) { + case TERM_LOGGING: + builder.loggingConsent(value); + break; + case TERM_CLOUDSYNC: + builder.cloudSyncConsent(value); + break; + case TERM_LOCATION: + builder.locationConsent(value); + break; + default: + throw new IllegalArgumentException("Not an opt-in term type: " + termType); + } } - @Override - public void doAncsPositiveAction(IWearableCallbacks callbacks, int i) throws RemoteException { - Log.d(TAG, "unimplemented Method: doAncsPositiveAction: " + i); + private static int[] getTermTypes(int termsContext) { + switch (termsContext) { + case TERMS_CONTEXT_UNSUPERVISED: + return new int[]{TERM_TOS, TERM_LOCATION, TERM_LOGGING, TERM_BACKUP, TERM_UPDATES, TERM_CLOUDSYNC}; + case TERMS_CONTEXT_SUPERVISED: + return new int[]{TERM_TOS, TERM_LOCATION, TERM_LOGGING, TERM_UPDATES}; + default: + return null; + } } - @Override - public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws RemoteException { - Log.d(TAG, "unimplemented Method: doAncsNegativeAction: " + i); + private static boolean contains(int[] values, int value) { + for (int v : values) if (v == value) return true; + return false; } - @Override - public void openChannel(IWearableCallbacks callbacks, String s1, String s2) throws RemoteException { - Log.d(TAG, "unimplemented Method: openChannel; " + s1 + ", " + s2); + private static int[] requireTermTypes(int termsContext, String parentGaiaId, String childGaiaId) { + if (termsContext == TERMS_CONTEXT_SUPERVISED + && (TextUtils.isEmpty(parentGaiaId) || TextUtils.isEmpty(childGaiaId))) { + throw new IllegalStateException("Consent requires parent and child Gaia Id: " + termsContext); + } + int[] termTypes = getTermTypes(termsContext); + if (termTypes == null) { + throw new IllegalArgumentException("Invalid TermsContext " + termsContext); + } + return termTypes; } - /* - * Channels - */ + private BackupAction doAcceptTerms(AcceptTermsRequest request) { + int[] valid = requireTermTypes(request.termsContext, request.parentGaiaId, request.childGaiaId); + List accepted = request.acceptedTermTypes; + if (accepted == null) accepted = Collections.emptyList(); + for (Object type : accepted) { + if (!(type instanceof Integer) || !contains(valid, (Integer) type)) { + throw new IllegalStateException("Accepted terms contains invalid term type for termsContext " + request.termsContext); + } + } - @Override - public void closeChannel(IWearableCallbacks callbacks, String s) throws RemoteException { - Log.d(TAG, "unimplemented Method: closeChannel: " + s); - } + String consentNodeId = null; + if (request.perWatchConsents) { + if (TextUtils.isEmpty(request.nodeId)) { + throw new IllegalStateException("Node ID not provided for consents per watch."); + } + consentNodeId = request.nodeId; + } - @Override - public void closeChannelWithError(IWearableCallbacks callbacks, String s, int errorCode) throws RemoteException { - Log.d(TAG, "unimplemented Method: closeChannelWithError:" + s + ", " + errorCode); + PrivacySettings.Builder builder = builderFrom(getPrivacySettings(consentNodeId)); + for (int type : new int[]{TERM_LOGGING, TERM_LOCATION, TERM_CLOUDSYNC}) { + if (contains(valid, type)) setOptIn(builder, type, accepted.contains(type)); + } + writePrivacySettings(builder, consentNodeId); + if (accepted.contains(TERM_BACKUP)) { + if (TextUtils.isEmpty(request.nodeId) || TextUtils.isEmpty(request.accountName)) { + Log.w(TAG, "acceptTerms called with TermType.BACKUP but missing nodeId or accountName."); + return null; + } + return new BackupAction(request.nodeId, request.accountName, true); + } + List skipped = request.skippedTermTypes; + if (skipped != null && skipped.contains(TERM_BACKUP) && !TextUtils.isEmpty(request.nodeId)) { + return new BackupAction(request.nodeId, null, false); + } + return null; } - @Override - public void getChannelInputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String s) throws RemoteException { - Log.d(TAG, "unimplemented Method: getChannelInputStream: " + s); + private void doRecordTermConsent(RecordTermConsentRequest request) { + int[] valid = requireTermTypes(request.termsContext, request.parentGaiaId, request.childGaiaId); + if (!contains(valid, request.termType)) { + throw new IllegalStateException("Invalid termType " + request.termType + " for termsContext " + request.termsContext); + } + if (!isOptInTermType(request.termType)) { + throw new IllegalStateException("Invalid term type for recordTermConsent"); + } + updateOptIn(request.accountId, request.termType, request.consentGranted); } - @Override - public void getChannelOutputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String s) throws RemoteException { - Log.d(TAG, "unimplemented Method: getChannelOutputStream: " + s); + private void updateOptIn(String nodeId, int termType, boolean value) { + PrivacySettings current = getPrivacySettings(nodeId); + if (current == null) { + Log.w(TAG, "updateOptIn: no existing consent record, creating one"); + } + PrivacySettings.Builder builder = builderFrom(current); + setOptIn(builder, termType, value); + writePrivacySettings(builder, nodeId); } @Override - public void writeChannelInputToFd(IWearableCallbacks callbacks, String s, ParcelFileDescriptor fd) throws RemoteException { - Log.d(TAG, "unimplemented Method: writeChannelInputToFd: " + s); + public void getConsentStatus(IWearableCallbacks callbacks) throws RemoteException { + postConsentResponse(callbacks, null); } @Override - public void readChannelOutputFromFd(IWearableCallbacks callbacks, String s, ParcelFileDescriptor fd, long l1, long l2) throws RemoteException { - Log.d(TAG, "unimplemented Method: readChannelOutputFromFd: " + s + ", " + l1 + ", " + l2); + public void addAccountToConsent(IWearableCallbacks callbacks, AddAccountToConsentRequest request) throws RemoteException { + Log.d(TAG, "unimplemented Method addAccountToConsent: " + + "account=" + request.accountName + + ", consent=" + request.consentGranted); + postMain(callbacks, () -> callbacks.onStatus(Status.SUCCESS)); } - @Override - public void syncWifiCredentials(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: syncWifiCredentials"); + @RequiresPermission(Manifest.permission.GET_ACCOUNTS) + private void doRecordSwaadlOptIn() { + PrivacySettings current = readPrivacySettings(null); + if (current == null) throw new IllegalStateException("Consent record not available"); + List records = new ArrayList<>(); + for (Account account : AccountManager.get(context).getAccountsByType("com.google")) { + records.add(new AccountConsentRecord.Builder() + .accountName(account.name) + .consentGranted(false) + .build()); + } + Log.d(TAG, "Writing sWAADL consent for " + records.size() + " account(s)"); + putPrivacySettings(current.newBuilder().accountConsents(records).build(), null); } - /* - * Connection deprecated - */ + private void sendBackupRequest(BackupAction action, StatusSink sink) { + String path; + byte[] payload; + if (action.enable) { + path = PATH_ENABLE_BACKUP; + payload = EnableBackupRequest.ADAPTER.encode(new EnableBackupRequest.Builder() + .accountName(action.accountName).inSetup(true).flag(false).build()); + } else { + path = PATH_ENABLE_BACKUP_SKIPPED; + payload = EnableBackupSkippedRequest.ADAPTER.encode(new EnableBackupSkippedRequest.Builder() + .fragmentType(FRAGMENT_COMPANION_TERMS_OF_SERVICE).flag(false).build()); + } + Log.d(TAG, "sendBackupRequest: " + path + " node=" + action.nodeId); + int messageId = wearable.sendRequest(packageName, action.nodeId, path, payload, new MessageOptions(0)); + if (messageId < 0) { + Log.w(TAG, "sendBackupRequest: sendRequest failed for " + path); + sink.onStatus(CommonStatusCodes.ERROR); + return; + } + wearable.getRpcHelper().addResponseListener(action.nodeId, messageId, ENABLE_BACKUP_RPC_TIMEOUT_MS, + data -> { + int code = CommonStatusCodes.SUCCESS; + if (data != null && data.length > 0) { + try { + BackupErrorResponse response = BackupErrorResponse.ADAPTER.decode(data); + if (response.errorCode != null) { + Log.w(TAG, path + " failed on the watch, error=" + response.errorCode); + code = CommonStatusCodes.ERROR; + } + } catch (IOException e) { + Log.w(TAG, path + ": failed to decode response", e); + code = CommonStatusCodes.ERROR; + } + } + sink.onStatus(code); + }, + () -> { + Log.w(TAG, path + ": RPC timeout for node " + action.nodeId); + sink.onStatus(CommonStatusCodes.ERROR); + }); + } - @Override - @Deprecated - public void putConnection(IWearableCallbacks callbacks, ConnectionConfiguration config) throws RemoteException { - Log.d(TAG, "unimplemented Method: putConnection"); + private void postStatus(IWearableCallbacks callbacks, String name, int statusCode) { + mainHandler.post(() -> { + try { + callbacks.onStatus(new Status(statusCode)); + } catch (RemoteException e) { + Log.w(TAG, name + ": onStatus failed", e); + } + }); + } + + private GetTermsResponse buildTermsResponse(int termsContext) { + List defs = WearableTerms.forContext(termsContext); + if (defs == null) { + Log.w(TAG, "No terms available for " + termsContext); + return new GetTermsResponse(CommonStatusCodes.INTERNAL_ERROR, Collections.emptyList()); + } + List terms = new ArrayList<>(); + for (WearableTerms.TermDef def : defs) { + terms.add(new Term(def.termType, def.getDescription(context), def.explicit, + def.getTitle(context), null, def.getOptInType())); + } + return new GetTermsResponse(CommonStatusCodes.SUCCESS, terms); + } + + @RequiresPermission(Manifest.permission.GET_ACCOUNTS) + @Override + public void acceptTerms(IWearableCallbacks callbacks, AcceptTermsRequest request) throws RemoteException { + Log.d(TAG, "acceptTerms: context=" + request.termsContext + ", accepted=" + request.acceptedTermTypes + + ", skipped=" + request.skippedTermTypes + ", node=" + request.nodeId + + ", perWatch=" + request.perWatchConsents); + wearable.networkHandler.post(() -> { + BackupAction backup; + try { + synchronized (CONSENT_LOCK) { + backup = doAcceptTerms(request); + } + } catch (Exception e) { + Log.w(TAG, "acceptTerms failed", e); + postStatus(callbacks, "acceptTerms", CommonStatusCodes.ERROR); + return; + } + try { + synchronized (CONSENT_LOCK) { + doRecordSwaadlOptIn(); + } + } catch (Exception e) { + Log.w(TAG, "acceptTerms: recordSwaadlOptIn failed", e); + } + if (backup == null) { + postStatus(callbacks, "acceptTerms", CommonStatusCodes.SUCCESS); + } else { + sendBackupRequest(backup, code -> postStatus(callbacks, "acceptTerms", code)); + } + }); + } + + @Override + public void recordTermConsent(IWearableCallbacks callbacks, RecordTermConsentRequest request) throws RemoteException { + Log.d(TAG, "recordTermConsent: context=" + request.termsContext + ", type=" + request.termType + + ", granted=" + request.consentGranted); + postConsentOperation(callbacks, "recordTermConsent", () -> doRecordTermConsent(request)); + } + + @Override + public void getTerms(IWearableCallbacks callbacks, int termsContext) throws RemoteException { + Log.d(TAG, "getTerms: " + termsContext); + postMain(callbacks, () -> callbacks.onGetTermsResponse(buildTermsResponse(termsContext))); + } + + @Override + public void getConsentStatusForRequest(IWearableCallbacks callbacks, ConsentStatusRequest request) throws RemoteException { + Log.d(TAG, "getConsentStatusForRequest: node=" + request.status); + postConsentResponse(callbacks, request.status); + } + + @Override + public void recordSwaadlOptIn(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "recordSwaadlOptIn"); + postConsentOperation(callbacks, "recordSwaadlOptIn", this::doRecordSwaadlOptIn); + } + + @Override + public void someBoolUnknown(IWearableCallbacks callbacks) throws RemoteException { + // not sure what it is, no-op in gms + postMain(callbacks, () -> { + try { + callbacks.onBooleanResponse(new BooleanResponse(0, true)); + } catch (Exception e) { + callbacks.onBooleanResponse(new BooleanResponse(8, false)); + } + }); + } + + @Override + public void logCounter(IWearableCallbacks callbacks, LogCounterRequest request) throws RemoteException { + Log.d(TAG, "logCounter: " + (request == null ? "null" + : request.counterName + ", value=" + request.value + ", increment=" + request.increment)); + postMain(callbacks, () -> { + if (request == null || TextUtils.isEmpty(request.counterName)) { + callbacks.onStatus(new Status(CommonStatusCodes.DEVELOPER_ERROR)); + return; + } + WearableLogStore.INSTANCE.counter(request.counterName, request.value, request.increment); + callbacks.onStatus(Status.SUCCESS); + }); + } + + @Override + public void logEvent(IWearableCallbacks callbacks, LogEventRequest request) throws RemoteException { + Log.d(TAG, "logEvent: data length=" + (request != null && request.eventData != null ? request.eventData.length : 0)); + postMain(callbacks, () -> { + if (request == null) { + callbacks.onStatus(new Status(CommonStatusCodes.DEVELOPER_ERROR)); + return; + } + WearableLogStore.INSTANCE.event(request.eventData != null ? request.eventData.length : 0); + callbacks.onStatus(Status.SUCCESS); + }); + } + + @Override + public void logTimer(IWearableCallbacks callbacks, LogTimerRequest request) throws RemoteException { + Log.d(TAG, "logTimer: " + (request == null ? "null" : request.timerName + ", timestamp=" + request.timestamp)); + postMain(callbacks, () -> { + if (request == null || TextUtils.isEmpty(request.timerName)) { + callbacks.onStatus(new Status(CommonStatusCodes.DEVELOPER_ERROR)); + return; + } + WearableLogStore.INSTANCE.timer(request.timerName, request.timestamp); + callbacks.onStatus(Status.SUCCESS); + }); + } + + @Override + public void clearLogs(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "clearLogs"); + postMain(callbacks, () -> { + WearableLogStore.INSTANCE.clear(); + callbacks.onStatus(Status.SUCCESS); + }); + } + + @Override + public void getBackupSettingsSupported(IWearableCallbacks callbacks, String nodeId) throws RemoteException { + Log.d(TAG, "getBackupSettingsSupported: nodeId=" + nodeId); + postMain(callbacks, () -> { + Set nodes = capabilities.getNodesForCapability( + CAPABILITY_BACKUP_SETTINGS); + if (nodes.contains(nodeId)) { + Log.d(TAG, "getBackupSettingsSupported: capability found for " + nodeId); + callbacks.onGetBackupSettingsSupportedResponse( + new GetBackupSettingsSupportedResponse(CommonStatusCodes.SUCCESS, true)); + return; + } + + Log.d(TAG, "getBackupSettingsSupported: no capability, trying RPC for " + nodeId); + wearable.networkHandler.post(new CallbackRunnable(callbacks) { + @Override + public void run(IWearableCallbacks cb) throws RemoteException { + int messageId = wearable.sendRequest(packageName, nodeId, + "/backup_settings/backup_supported", null, new MessageOptions(0)); + if (messageId < 0) { + Log.w(TAG, "getBackupSettingsSupported: sendRequest failed"); + mainHandler.post(() -> { + try { + cb.onGetBackupSettingsSupportedResponse( + new GetBackupSettingsSupportedResponse( + CommonStatusCodes.ERROR, false)); + } catch (RemoteException ignored) { + } + }); + return; + } + wearable.getRpcHelper().addResponseListener(nodeId, messageId, + BACKUP_RPC_TIMEOUT_MS, responseData -> { + boolean supported = false; + try { + supported = Boolean.TRUE.equals( + BackupBoolResponse.ADAPTER.decode(responseData).value); + } catch (IOException e) { + Log.w(TAG, "parseProtoBool: failed to decode response", e); + } + boolean finalSupported = supported; + mainHandler.post(() -> { + try { + cb.onGetBackupSettingsSupportedResponse( + new GetBackupSettingsSupportedResponse( + CommonStatusCodes.SUCCESS, finalSupported)); + } catch (RemoteException ignored) { + } + }); + }, () -> { + Log.w(TAG, "getBackupSettingsSupported: RPC timeout for " + nodeId); + mainHandler.post(() -> { + try { + cb.onGetBackupSettingsSupportedResponse( + new GetBackupSettingsSupportedResponse( + CommonStatusCodes.ERROR, false)); + } catch (RemoteException ignored) { + } + }); + }); + } + }); + }); + } + + @Override + public void getRestoreSupported(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "getRestoreSupported"); + postMain(callbacks, () -> + callbacks.onGetRestoreSupportedResponse( + new GetRestoreSupportedResponse(CommonStatusCodes.SUCCESS, true))); + } + + @Override + public void startRestoreSession(IWearableCallbacks callbacks, StartRestoreSessionRequest request) throws RemoteException { + Log.d(TAG, "startRestoreSession: nodeId=" + request.nodeId); + postMain(callbacks, () -> { + int reqId = wearable.sendMessage(packageName, request.nodeId, + "/restore/restore_finished", null, new MessageOptions(0)); + if (reqId < 0) { + Log.w(TAG, "startRestoreSession: sendMessage failed for node " + request.nodeId); + } + callbacks.onStatus(Status.SUCCESS); + }); + } + + @Override + public void saveRestoreState(IWearableCallbacks callbacks, SaveRestoreStateRequest request) throws RemoteException { + Log.d(TAG, "saveRestoreState: nodeId=" + request.nodeId + " state=" + request.state); + postMain(callbacks, () -> { + if (request.nodeId == null || request.data == null) { + Log.w(TAG, "saveRestoreState: null nodeId or data"); + callbacks.onStatus(new Status(CommonStatusCodes.ERROR)); + return; + } + restoreStateByNode.put(request.nodeId, request.state); + restoreDataByNode.put(request.nodeId, request.data); + callbacks.onStatus(Status.SUCCESS); + }); + } + + @Override + public void getRestoreState(IWearableCallbacks callbacks, GetRestoreStateRequest request) throws RemoteException { + Log.d(TAG, "getRestoreState: nodeId=" + request.nodeId); + postMain(callbacks, () -> { + Integer state = restoreStateByNode.get(request.nodeId); + byte[] data = restoreDataByNode.get(request.nodeId); + if (state == null || data == null) { + Log.w(TAG, "getRestoreState: no saved state for node " + request.nodeId); + callbacks.onGetRestoreStateResponse( + new GetRestoreStateResponse(CommonStatusCodes.ERROR, 0, new byte[0])); + return; + } + callbacks.onGetRestoreStateResponse( + new GetRestoreStateResponse(CommonStatusCodes.SUCCESS, state, data)); + }); + } + + @Override + public void getBackupEnabled(IWearableCallbacks callbacks, String nodeId) throws RemoteException { + Log.d(TAG, "getBackupEnabled: nodeId=" + nodeId); + wearable.networkHandler.post(new CallbackRunnable(callbacks) { + @Override + public void run(IWearableCallbacks cb) throws RemoteException { + int messageId = wearable.sendRequest(packageName, nodeId, + "/backup_settings/backup_enabled", + null, new MessageOptions(0)); + if (messageId < 0) { + Log.w(TAG, "getBackupSettingsSupported: sendRequest failed"); + mainHandler.post(() -> { + try { + cb.onGetBackupSettingsSupportedResponse( + new GetBackupSettingsSupportedResponse( + CommonStatusCodes.ERROR, false)); + } catch (RemoteException ignored) { + } + }); + return; + } + wearable.getRpcHelper().addResponseListener(nodeId, messageId, + BACKUP_RPC_TIMEOUT_MS, responseData -> { + boolean enabled = false; + try { + enabled = Boolean.TRUE.equals( + BackupBoolResponse.ADAPTER.decode(responseData).value); + } catch (IOException e) { + Log.w(TAG, "parseProtoBool: failed to decode response", e); + } + boolean finalEnabled = enabled; + mainHandler.post(() -> { + try { + cb.onBooleanResponse( + new BooleanResponse(CommonStatusCodes.SUCCESS, finalEnabled)); + } catch (RemoteException ignored) { + } + }); + }, () -> { + Log.w(TAG, "getBackupEnabled: RPC timeout for node " + nodeId); + mainHandler.post(() -> { + try { + cb.onBooleanResponse( + new BooleanResponse(CommonStatusCodes.ERROR, false)); + } catch (RemoteException ignored) { + } + }); + }); + } + }); + } + + @Override + public void dataSynchronizationProgressTracking(IWearableCallbacks callbacks, String peerNodeId) throws RemoteException { + Log.d(TAG, "dataSynchronizationProgressTracking: peer=" + peerNodeId); + + if (TextUtils.isEmpty(peerNodeId)) { + callbacks.onStatus(new Status(CommonStatusCodes.ERROR, "peerNodeId cannot be empty")); + return; + } + + wearable.networkHandler.post(new CallbackRunnable(callbacks) { + @Override + public void run(IWearableCallbacks callbacks) throws RemoteException { + String trackerId = UUID.randomUUID().toString(); + long targetSeqId = wearable.getNodeDatabase().getCurrentSeqId(wearable.getLocalNodeId()); + Log.d(TAG, "dataSynchronizationProgressTracking: trackerId=" + trackerId + + " targetSeqId=" + targetSeqId + " peer=" + peerNodeId); + DataSyncTrackingMessage request = new DataSyncTrackingMessage.Builder() + .trackerId(trackerId).sequenceId(targetSeqId).build(); + byte[] payload = DataSyncTrackingMessage.ADAPTER.encode(request); + + wearable.getRpcHelper().addDataSyncListener(peerNodeId, trackerId, DATA_SYNC_TRACKING_TIMEOUT_MS, + reachedSeqId -> { + Log.d(TAG, "dataSynchronizationProgressTracking: peer=" + peerNodeId + + " confirmed seqId=" + reachedSeqId); + mainHandler.post(() -> { + try { + callbacks.onStatus(Status.SUCCESS); + } catch (RemoteException ignored) {} + }); + }, () -> { + Log.w(TAG, "dataSynchronizationProgressTracking: timeout for peer=" + peerNodeId); + mainHandler.post(() -> { + try { + callbacks.onStatus(new Status(CommonStatusCodes.TIMEOUT)); + } catch (RemoteException ignored) {} + }); + }); + int reqId = wearable.sendMessage(packageName, peerNodeId, DATA_SYNC_PROGRESS_PATH, + payload, new MessageOptions(0)); + if (reqId < 0) { + wearable.getRpcHelper().cancelDataSyncListener(peerNodeId, trackerId); + Log.w(TAG, "dataSynchronizationProgressTracking: sendMessage failed"); + mainHandler.post(() -> { + try { + callbacks.onStatus(new Status(CommonStatusCodes.ERROR)); + } catch (RemoteException ignored) {} + }); + } + } + }); + } + + private static final int INVALID_SUBSCRIPTION_ID = -1; + private static final int APPTYPE_USIM = 2; + private static final int AUTHTYPE_EAP_AKA = 129; + private static final String EAP_IDENTITY_PREFIX = "0"; + + private TelephonyManager getTelephonyManager(int subscriptionId) { + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm == null) return null; + if (android.os.Build.VERSION.SDK_INT >= 26) { + return tm.createForSubscriptionId(subscriptionId); + } + return tm; + } + + private static String iccAuthentication(TelephonyManager tm, String challenge) { + if (android.os.Build.VERSION.SDK_INT >= 26) { + return tm.getIccAuthentication(APPTYPE_USIM, TelephonyManager.AUTHTYPE_EAP_AKA, challenge); + } + try { + Method m = TelephonyManager.class.getMethod("getIccAuthentication", int.class, int.class, String.class); + return (String) m.invoke(tm, APPTYPE_USIM, AUTHTYPE_EAP_AKA, challenge); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof SecurityException) throw (SecurityException) e.getCause(); + return null; + } catch (NoSuchMethodException | IllegalAccessException e) { + return null; + } + } + + private static String buildEapIdentity(String imsi, String simOperator) { + if (TextUtils.isEmpty(imsi) || TextUtils.isEmpty(simOperator) || simOperator.length() < 5) { + return null; + } + String mcc = simOperator.substring(0, 3); + String mnc = simOperator.substring(3); + if (mnc.length() == 2) mnc = "0" + mnc; + return EAP_IDENTITY_PREFIX + imsi + "@wlan.mnc" + mnc + ".mcc" + mcc + ".3gppnetwork.org"; + } + + @RequiresPermission("android.permission.READ_PRIVILEGED_PHONE_STATE") + @Override + public void getEapId(IWearableCallbacks callbacks, int subscriptionId) throws RemoteException { + Log.d(TAG, "getEapId: subscriptionId=" + subscriptionId); + postMain(callbacks, () -> { + if (subscriptionId == INVALID_SUBSCRIPTION_ID) { + callbacks.onGetEapIdResponse(new GetEapIdResponse(CommonStatusCodes.DEVELOPER_ERROR, "")); + return; + } + String eapId = null; + try { + TelephonyManager tm = getTelephonyManager(subscriptionId); + if (tm != null) eapId = buildEapIdentity(tm.getSubscriberId(), tm.getSimOperator()); + } catch (SecurityException e) { + Log.w(TAG, "getEapId: missing phone state permission", e); + } catch (RuntimeException e) { + Log.w(TAG, "getEapId failed", e); + } + callbacks.onGetEapIdResponse(TextUtils.isEmpty(eapId) + ? new GetEapIdResponse(CommonStatusCodes.ERROR, "") + : new GetEapIdResponse(CommonStatusCodes.SUCCESS, eapId)); + }); + } + + @Override + public void performEapAka(IWearableCallbacks callbacks, int subscriptionId, String challenge) throws RemoteException { + Log.d(TAG, "performEapAka: subscriptionId=" + subscriptionId); + postMain(callbacks, () -> { + if (subscriptionId == INVALID_SUBSCRIPTION_ID || TextUtils.isEmpty(challenge)) { + callbacks.onPerformEapAkaResponse(new PerformEapAkaResponse(CommonStatusCodes.DEVELOPER_ERROR, "")); + return; + } + try { + Base64.decode(challenge, Base64.DEFAULT); + } catch (IllegalArgumentException e) { + callbacks.onPerformEapAkaResponse(new PerformEapAkaResponse(CommonStatusCodes.DEVELOPER_ERROR, "")); + return; + } + + String result = null; + try { + TelephonyManager tm = getTelephonyManager(subscriptionId); + if (tm != null) result = iccAuthentication(tm, challenge); + } catch (SecurityException e) { + Log.w(TAG, "performEapAka: missing privileged phone state permission", e); + } catch (RuntimeException e) { + Log.w(TAG, "performEapAka failed", e); + } + + callbacks.onPerformEapAkaResponse(TextUtils.isEmpty(result) + ? new PerformEapAkaResponse(CommonStatusCodes.ERROR, "") + : new PerformEapAkaResponse(CommonStatusCodes.SUCCESS, result)); + }); + } + + @Override + public void associateDeviceAndAccountWithFastPair(IWearableCallbacks callbacks, String s1, Account account, String s2, String s3) throws RemoteException { + Log.d(TAG, "associateDeviceAndAccountWithFastPair: " + packageName + " " + s3 + " " + s2 + " " + s1); + postMain(callbacks, () -> { + if (account == null || TextUtils.isEmpty(account.name)) { + callbacks.onStatus(new Status(CommonStatusCodes.DEVELOPER_ERROR)); + return; + } + try { + getFastPairManager().getOrCreateAccountKey(account.name); + callbacks.onStatus(Status.SUCCESS); + } catch (Exception e) { + Log.e(TAG, "associateDeviceAndAccountWithFastPair failed", e); + callbacks.onStatus(new Status(CommonStatusCodes.INTERNAL_ERROR)); + } + }); + + } + + @Override + public void getFastpairAccountKeys(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "getFastpairAccountKeys"); + postMain(callbacks, () -> { + try { + List keys = new ArrayList<>(); + for (WearFastPairManager.AccountKeyRecord r : getFastPairManager().getAccountKeys()) { + keys.add(new FastPairAccountKeyParcelable(r.accountKey)); + } + callbacks.onGetFastpairAccountKeysResponse( + new GetFastpairAccountKeysResponse(CommonStatusCodes.SUCCESS, keys)); + } catch (Exception e) { + Log.e(TAG, "getFastpairAccountKeys failed", e); + callbacks.onGetFastpairAccountKeysResponse( + new GetFastpairAccountKeysResponse(CommonStatusCodes.INTERNAL_ERROR, null)); + } + }); + } + + @Override + public void getFastpairAccountKeyByAccount(IWearableCallbacks callbacks, Account account) throws RemoteException { + Log.d(TAG, "getFastpairAccountKeyByAccount: " + (account == null ? "null" : account.name)); + + String[] pkgs = context.getPackageManager().getPackagesForUid(Binder.getCallingUid()); + if (pkgs == null || !java.util.Arrays.asList(pkgs).contains(packageName)) { + throw new SecurityException(String.format("Package [%s] is not authorized", packageName)); + } + + postMain(callbacks, () -> { + if (account == null || TextUtils.isEmpty(account.name)) { + callbacks.onGetFastpairAccountKeyByAccountResponse( + new GetFastpairAccountKeyByAccountResponse(CommonStatusCodes.DEVELOPER_ERROR, null)); + return; + } + try { + WearFastPairManager.AccountKeyRecord r = getFastPairManager().getAccountKey(account.name); + callbacks.onGetFastpairAccountKeyByAccountResponse(new GetFastpairAccountKeyByAccountResponse( + CommonStatusCodes.SUCCESS, + r == null ? null : new FastPairAccountKeyParcelable(r.accountKey))); + } catch (Exception e) { + Log.e(TAG, "getFastpairAccountKeyByAccount failed", e); + callbacks.onGetFastpairAccountKeyByAccountResponse( + new GetFastpairAccountKeyByAccountResponse(CommonStatusCodes.INTERNAL_ERROR, null)); + } + }); + + } + + @Override + public void getAppRecommendations(IWearableCallbacks callbacks, AppRecommendationsRequest request) throws RemoteException { + Log.d(TAG, "getAppRecommendations: " + request); + postMain(callbacks, () -> callbacks.onAppRecommendationsResponse( + new AppRecommendationsResponse(CommonStatusCodes.CANCELED))); + } + + @Override + public void setThemeForApp(IWearableCallbacks callbacks, AppTheme theme) throws RemoteException { + Log.d(TAG, "setThemeForApp: " + theme); + postNetwork(callbacks, () -> { + int status; + try { + if (theme == null) throw new IllegalArgumentException("theme is null"); + int color = inRange(theme.colorTheme, 3); + int dynamic = inRange(theme.dynamicColor, 2); + int align = inRange(theme.screenAlignment, 2); + int size = inRange(theme.screenItemsSize, 3); + context.getSharedPreferences(PREFS_APP_THEMES, Context.MODE_PRIVATE).edit() + .putString(this.packageName, color + "," + dynamic + "," + align + "," + size) + .commit(); + status = CommonStatusCodes.SUCCESS; + } catch (RuntimeException e) { + Log.w(TAG, "setThemeForApp failed", e); + status = CommonStatusCodes.INTERNAL_ERROR; + } + callbacks.onStatus(new Status(status)); + }); + } + + @Override + public void getThemeForApp(IWearableCallbacks callbacks, String targetPackage) throws RemoteException { + Log.d(TAG, "getThemeForApp: " + targetPackage); + postNetwork(callbacks, () -> { + int status; + AppTheme result = null; + try { + if (TextUtils.isEmpty(targetPackage)) throw new IllegalArgumentException("empty package"); + result = loadAppTheme(targetPackage); + status = CommonStatusCodes.SUCCESS; + } catch (RuntimeException e) { + Log.w(TAG, "getThemeForApp failed", e); + status = CommonStatusCodes.INTERNAL_ERROR; + } + callbacks.onGetAppThemeResponse(new GetAppThemeResponse(status, result)); + }); + } + + private static int inRange(int v, int max) { return (v < 0 || v > max) ? 0 : v; } + + private AppTheme loadAppTheme(String pkg) { + int[] v = new int[4]; + String raw = context.getSharedPreferences(PREFS_APP_THEMES, Context.MODE_PRIVATE).getString(pkg, null); + if (raw != null) { + String[] p = raw.split(","); + try { + if (p.length == 4) for (int i = 0; i < 4; i++) v[i] = Integer.parseInt(p[i]); + } catch (NumberFormatException e) { + v = new int[4]; + } + } + // defaults from gms: SYSTEM, dynamic color enabled, START alignment, LARGE items + return new AppTheme(v[0] == 0 ? 1 : v[0], v[1] == 0 ? 1 : v[1], v[2] == 0 ? 1 : v[2], v[3] == 0 ? 3 : v[3]); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + @Override + public void retryConnection(IWearableCallbacks callbacks, String nodeId, boolean force) throws RemoteException { + Log.d(TAG, "retryConnection: nodeId=" + nodeId + ", force=" + force); + postMain(callbacks, () -> { + if (TextUtils.isEmpty(nodeId)) { + Log.w(TAG, "retryConnection: empty nodeId"); + callbacks.onStatus(new Status(WearableStatusCodes.INVALID_TARGET_NODE)); + return; + } + boolean ok = wearable.retryConnection(nodeId, force); + callbacks.onStatus(ok ? Status.SUCCESS + : new Status(WearableStatusCodes.TARGET_NODE_NOT_CONNECTED)); + }); + } + + @Override + public void cancelMigration(IWearableCallbacks callbacks, ConnectionConfiguration config) throws RemoteException { + Log.d(TAG, "cancelMigration: " + config); + postMain(callbacks, () -> { + boolean ok = wearable.cancelNodeMigration(config); + callbacks.onStatus(ok ? Status.SUCCESS : new Status(CommonStatusCodes.ERROR)); + }); + } + + @Override + public void isNodeConnectionMetered(IWearableCallbacks callbacks, String nodeId) throws RemoteException { + Log.d(TAG, "isNodeConnectionMetered: " + nodeId); + postMain(callbacks, () -> { + callbacks.onBooleanResponse(new BooleanResponse(CommonStatusCodes.SUCCESS, false)); + }); + } + + @Override + public void setConnectionDelayConfig(IWearableCallbacks callbacks, ConnectionDelayConfig config) throws RemoteException { + Log.d(TAG, "setConnectionDelayConfig: " + config); + postMain(callbacks, () -> callbacks.onStatus(new Status(WEAR_FEATURE_DISABLED))); + } + + @Override + public void connectionDelayUnknown101(IWearableCallbacks callbacks, String s) throws RemoteException { + Log.d(TAG, "connectionDelayUnknown101: " + s); + postMain(callbacks, () -> callbacks.onStatus(new Status(WEAR_FEATURE_DISABLED))); + } + + @Override + public void clearConnectionDelayConfig(IWearableCallbacks callbacks, String nodeId) throws RemoteException { + Log.d(TAG, "clearConnectionDelayConfig: " + nodeId); + postMain(callbacks, () -> callbacks.onStatus(new Status(WEAR_FEATURE_DISABLED))); + } + + @Override + public void addSupervisedAccount(IWearableCallbacks callbacks, AddSupervisedAccountRequest request) throws RemoteException { + Log.d(TAG, "addSupervisedAccount"); + postMain(callbacks, () -> callbacks.onStatus(new Status(WEAR_FEATURE_DISABLED))); + } + + @Override + public void recordUntetheredSupervisedAccountTransfer(IWearableCallbacks callbacks, RecordUntetheredSupervisedAccountTransferRequest request) throws RemoteException { + Log.d(TAG, "recordUntetheredSupervisedAccountTransfer"); + postMain(callbacks, () -> callbacks.onStatus(new Status(WEAR_FEATURE_DISABLED))); + } + + @Override + public void getLocalNode(IWearableCallbacks callbacks) throws RemoteException { + postMain(callbacks, () -> { + try { + callbacks.onGetLocalNodeResponse(new GetLocalNodeResponse(0, new NodeParcelable(wearable.getLocalNodeId(), Build.MODEL))); + } catch (Exception e) { + callbacks.onGetLocalNodeResponse(new GetLocalNodeResponse(8, null)); + } + }); + } + + @Override + public void getNodeId(IWearableCallbacks callbacks, String address) throws RemoteException { + postNetwork(callbacks, () -> { + String resultNode; + ConnectionConfiguration configuration = wearable.getConfigurationByAddress(address); + try { + if (address == null || configuration == null || configuration.type == 4 || !address.equals(configuration.address)) { + resultNode = null; + } else { + resultNode = configuration.peerNodeId; + } + + if (resultNode != null) + callbacks.onGetNodeIdResponse(new GetNodeIdResponse(0, resultNode)); + else + callbacks.onGetNodeIdResponse(new GetNodeIdResponse(13, null)); + + } catch (Exception e) { + callbacks.onGetNodeIdResponse(new GetNodeIdResponse(8, null)); + } + }); + } + + @Override + public void getConnectedNodes(IWearableCallbacks callbacks) throws RemoteException { + postMain(callbacks, () -> { + callbacks.onGetConnectedNodesResponse(new GetConnectedNodesResponse(0, wearable.getConnectedNodesParcelableList())); + }); + } + + /* + * Capability + */ + + @Override + public void getConnectedCapability(IWearableCallbacks callbacks, String capability, int nodeFilter) throws RemoteException { + Log.d(TAG, "getConnectedCapability: " + capability + ", nodeFilter=" + nodeFilter); + postMain(callbacks, () -> { + try { + List nodes = new ArrayList<>(); + Set nodeIds = capabilities.getNodesForCapability(capability); + final Set reachable = reachableNodeIds(); + + for (String nodeId : nodeIds) { + if (shouldIncludeNode(nodeId, nodeFilter, reachable)) { + ConnectionConfiguration cc = wearable.getConfigurationByNodeId(nodeId); + if (cc == null) cc = wearable.getConfigurationByPeerNodeId(nodeId); + String dispName = (cc != null && cc.name != null) ? cc.name : nodeId; + nodes.add(new NodeParcelable(nodeId, dispName)); + } + } + + CapabilityInfoParcelable capabilityInfo = new CapabilityInfoParcelable(capability, nodes); + callbacks.onGetCapabilityResponse(new GetCapabilityResponse(0, capabilityInfo)); + } catch (Exception e) { + Log.e(TAG, "getConnectedCapability failed", e); + callbacks.onGetCapabilityResponse(new GetCapabilityResponse(13, null)); + } + }); + } + + @Override + public void getAllCapabilities(IWearableCallbacks callbacks, int nodeFilter) throws RemoteException { + Log.d(TAG, "getAllCapabilities: nodeFilter=" + nodeFilter); + postMain(callbacks, () -> { + try { + Map capabilitiesMap = new HashMap<>(); + final Set reachable = reachableNodeIds(); + + DataHolder dataHolder = wearable.getDataItemsByUriAsHolder( + Uri.parse("wear:/capabilities/"), packageName + ); + + try { + Set processedCapabilities = new HashSet<>(); + + for (int i = 0; i < dataHolder.getCount(); i++) { + String uri = dataHolder.getString("path", i, 0); + if (uri != null && uri.startsWith("/capabilities/")) { + String[] segments = uri.split("/"); + if (segments.length >= 4) { + String capabilityName = Uri.decode(segments[segments.length - 1]); + if (!processedCapabilities.contains(capabilityName)) { + processedCapabilities.add(capabilityName); + + List nodes = new ArrayList<>(); + Set nodeIds = capabilities.getNodesForCapability(capabilityName); + + for (String nodeId: nodeIds) { + if (shouldIncludeNode(nodeId, nodeFilter, reachable)){ + ConnectionConfiguration cc = wearable.getConfigurationByNodeId(nodeId); + if (cc == null) cc = wearable.getConfigurationByPeerNodeId(nodeId); + String dispName = (cc != null && cc.name != null) ? cc.name : nodeId; + nodes.add(new NodeParcelable(nodeId, dispName)); + } + } + + if (!nodes.isEmpty() || nodeFilter == 0) { + capabilitiesMap.put(capabilityName, new CapabilityInfoParcelable(capabilityName, nodes)); + } + } + } + } + } + } finally { + dataHolder.close(); + } + } catch (Exception e) { + Log.e(TAG, "getAllCapabilities failed", e); + callbacks.onGetAllCapabilitiesResponse(new GetAllCapabilitiesResponse(13, new ArrayList<>())); + } + }); + } + + private Set reachableNodeIds() { + Set ids = new HashSet<>(); + String local = wearable.getLocalNodeId(); + if (local != null) ids.add(local); + for (NodeParcelable n : wearable.getConnectedNodesParcelableList()) + ids.add(n.getId()); + + return ids; + } + + private boolean shouldIncludeNode(String nodeId, int nodeFilter, Set reachable) { + switch (nodeFilter) { + case 0: + return true; + case 1: + case 2: + return reachable.contains(nodeId); + default: + Log.w(TAG, "Unknown node filter: " + nodeFilter + ", including all nodes"); + return true; + } + } + + @Override + public void addLocalCapability(IWearableCallbacks callbacks, String capability) throws RemoteException { + Log.d(TAG, "addLocalCapability: " + capability); + + this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { + @Override + public void run(IWearableCallbacks callbacks) throws RemoteException { + try { + int statusCode = capabilities.add(capability); + callbacks.onAddLocalCapabilityResponse(new AddLocalCapabilityResponse(statusCode)); + + if (statusCode == 0) { + Log.d(TAG, "Successfully added local capability: " + capability); + } else { + Log.w(TAG, "Failed to add local capability: " + capability + ", status=" + statusCode); + } + } catch (Exception e) { + Log.e(TAG, "addLocalCapability exception", e); + callbacks.onAddLocalCapabilityResponse(new AddLocalCapabilityResponse(8)); + } + } + }); + } + + @Override + public void removeLocalCapability(IWearableCallbacks callbacks, String capability) throws RemoteException { + Log.d(TAG, "removeLocalCapability: " + capability); + + this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { + @Override + public void run(IWearableCallbacks callbacks) throws RemoteException { + try { + int statusCode = capabilities.remove(capability); + callbacks.onRemoveLocalCapabilityResponse(new RemoveLocalCapabilityResponse(statusCode)); + + if (statusCode == 0) { + Log.d(TAG, "Successfully removed local capability: " + capability); + } else { + Log.w(TAG, "Failed to remove local capability: " + capability + ", status=" + statusCode); + } + + } catch (Exception e) { + Log.e(TAG, "removeLocalCapability exception", e); + callbacks.onRemoveLocalCapabilityResponse(new RemoveLocalCapabilityResponse(8)); + } + } + }); + } + + @Override + public void addListener(IWearableCallbacks callbacks, AddListenerRequest request) throws RemoteException { + if (request.listener != null) { + wearable.addListener(packageName, request.listener, request.intentFilters); + } + callbacks.onStatus(Status.SUCCESS); + } + + @Override + public void removeListener(IWearableCallbacks callbacks, RemoveListenerRequest request) throws RemoteException { + wearable.removeListener(request.listener); + callbacks.onStatus(Status.SUCCESS); + } + + @Override + public void getStorageInformation(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "getStorageInformation"); + postMain(callbacks, () -> { + try { + NodeDatabaseHelper nodeDatabase = wearable.getNodeDatabase(); + PackageManager packageManager = context.getPackageManager(); + SQLiteDatabase db = nodeDatabase.getReadableDatabase(); + + File databasePath = context.getDatabasePath("node.db"); + long totalDatabaseSize = databasePath != null ? databasePath.length() : 0L; + + Map packageInfoMap = new HashMap<>(); + + Map packageIdToName = new HashMap<>(); + Cursor appKeysCursor = db.query("appkeys", new String[]{"_id", "packageName"}, + null, null, null, null, null); + + while (appKeysCursor.moveToNext()) { + String id = appKeysCursor.getString(0); + String packageName = appKeysCursor.getString(1); + packageIdToName.put(id, packageName); + } + appKeysCursor.close(); + + for (Map.Entry entry : packageIdToName.entrySet()) { + String appKeyId = entry.getKey(); + String packageName = entry.getValue(); + + long dataItemsSize = getTableSizeForAppKey(db, "dataitems", "appkeys_id", appKeyId); + + long assetsSize = getAssetsSizeForPackage(db, nodeDatabase, appKeyId); + + String appLabel = packageName; + try { + ApplicationInfo appInfo = packageManager.getApplicationInfo(packageName, 0); + CharSequence label = packageManager.getApplicationLabel(appInfo); + if (label != null) { + appLabel = label.toString(); + } + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, "Package not found: " + packageName); + } + + long totalSize = dataItemsSize + assetsSize; + + if (totalSize > 0) { + PackageStorageInfo info = new PackageStorageInfo( + packageName, + appLabel, + totalSize + ); + packageInfoMap.put(packageName, info); + } + } + + List packageInfoList = new ArrayList<>(packageInfoMap.values()); + PackageStorageInfo[] packageInfoArray = packageInfoList.toArray( + new PackageStorageInfo[packageInfoList.size()]); + + StorageInfoResponse response = new StorageInfoResponse( + CommonStatusCodes.SUCCESS, + totalDatabaseSize, + packageInfoArray + ); + + Log.d(TAG, "getStorageInformation: total db size=" + totalDatabaseSize + + ", packages=" + packageInfoArray.length); + callbacks.onStorageInfoResponse(response); + + } catch (Exception e) { + Log.e(TAG, "getStorageInformation: exception during processing", e); + try { + callbacks.onStorageInfoResponse(new StorageInfoResponse( + CommonStatusCodes.INTERNAL_ERROR, 0L, new PackageStorageInfo[0])); + } catch (RemoteException re) { + Log.w(TAG, "Failed to send error response", re); + } + } + }); + } + + @Override + public void privacyRecordOptinRequest(IWearableCallbacks callbacks, + PrivacyRecordOptinRequest request) + throws RemoteException { + Log.d(TAG, "PrivacyRecordOptinRequest: type=" + request.optInType + + " optedIn=" + request.optedIn + " node=" + request.nodeId); + // types: 1=LOGGING, 2=CLOUDSYNC, 3=LOCATION, 4=BACKUP + if (request.optInType < 1 || request.optInType > 4) { + Log.e(TAG, "onPrivacyRecordOptinRequest: invalid optInType " + request.optInType); + postMain(callbacks, () -> callbacks.onStatus(new Status(CommonStatusCodes.ERROR))); + return; + } + if (request.optInType == OPT_IN_TYPE_BACKUP) { + postMain(callbacks, () -> callbacks.onStatus(Status.SUCCESS)); + return; + } + postConsentOperation(callbacks, "recordOptIn", + () -> updateOptIn(request.nodeId, request.optInType, request.optedIn)); + } + + private long getTableSizeForAppKey(SQLiteDatabase db, String tableName, + String keyColumn, String appKeyId) { + long totalSize = 0; + + Cursor cursor = db.query(tableName, null, keyColumn + "=?", + new String[]{appKeyId}, null, null, null); + + int rowCount = cursor.getCount(); + cursor.close(); + + totalSize = rowCount * 1024L; + + return totalSize; + } + + private long getAssetsSizeForPackage(SQLiteDatabase db, NodeDatabaseHelper nodeDatabase, + String appKeyId) { + long totalSize = 0; + + try { + Set assetDigests = new HashSet<>(); + Cursor aclCursor = db.query("assetsacls", new String[]{"assets_digest"}, + "appkeys_id=?", new String[]{appKeyId}, null, null, null); + + while (aclCursor.moveToNext()) { + String digest = aclCursor.getString(0); + assetDigests.add(digest); + } + aclCursor.close(); + + for (String digest : assetDigests) { + File assetFile = new File(context.getFilesDir(), "assets/" + digest); + if (assetFile.exists()) { + totalSize += assetFile.length(); + } + } + } catch (Exception e) { + Log.w(TAG, "Error calculating assets size", e); + } + + return totalSize; + } + + @Override + public void clearStorage(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "clearStorage"); + + postMain(callbacks, () -> { + try { + Log.d(TAG, "clearStorage: starting storage clear"); + + NodeDatabaseHelper nodeDatabase = wearable.getNodeDatabase(); + SQLiteDatabase db = nodeDatabase.getWritableDatabase(); + + db.execSQL("DELETE FROM dataitems"); + db.execSQL("DELETE FROM assets"); + db.execSQL("DELETE FROM assetrefs"); + db.execSQL("DELETE FROM assetsacls"); + db.execSQL("DELETE FROM nodeinfo"); + db.execSQL("DELETE FROM appkeys"); + db.execSQL("DELETE FROM archiveDataItems"); + db.execSQL("DELETE FROM archiveAssetRefs"); + + Log.d(TAG, "clearStorage: database tables cleared"); + + File assetsDir = new File(context.getFilesDir(), "assets"); + if (assetsDir.exists() && assetsDir.isDirectory()) { + File[] assetFiles = assetsDir.listFiles(); + if (assetFiles != null) { + for (File file : assetFiles) { + if (file.isFile()) { + file.delete(); + } + } + } + } + Log.d(TAG, "clearStorage: asset files cleared"); + + ClockworkNodePreferences prefs = wearable.getClockworkNodePreferences(); + if (prefs != null) { + prefs.clear(); + } + Log.d(TAG, "clearStorage: preferences cleared"); + + callbacks.onStatus(Status.SUCCESS); + + Log.d(TAG, "clearStorage: complete"); + + } catch (Exception e) { + Log.e(TAG, "clearStorage: exception during clearing storage", e); + try { + callbacks.onStatus(new Status(CommonStatusCodes.INTERNAL_ERROR)); + } catch (RemoteException re) { + Log.w(TAG, "Failed to send error response", re); + } + } + }); + + } + + @Override + public void endCall(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "unimplemented Method: endCall"); + } + + @Override + public void acceptRingingCall(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "unimplemented Method: acceptRingingCall"); + } + + @Override + public void silenceRinger(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "unimplemented Method: silenceRinger"); + } + + /* + * Apple Notification Center Service + */ + + @Override + public void injectAncsNotificationForTesting(IWearableCallbacks callbacks, AncsNotificationParcelable notification) throws RemoteException { + Log.d(TAG, "unimplemented Method: injectAncsNotificationForTesting: " + notification); + } + + @Override + public void doAncsPositiveAction(IWearableCallbacks callbacks, int i) throws RemoteException { + Log.d(TAG, "unimplemented Method: doAncsPositiveAction: " + i); + } + + @Override + public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws RemoteException { + Log.d(TAG, "unimplemented Method: doAncsNegativeAction: " + i); + } + + /* + * Channels + */ + + @Override + public void openChannel(IWearableCallbacks callbacks, String nodeId, String path) throws RemoteException { + Log.d(TAG, "openChannel; " + nodeId + ", " + path); + ChannelManager channelManager = getChannelManager(); + if (channelManager == null) { + Log.w(TAG, "openChannel: ChannelManager not initialized"); + callbacks.onOpenChannelResponse(new OpenChannelResponse(ChannelStatusCodes.INTERNAL_ERROR, null)); + return; + } + + try { + if (nodeId == null || nodeId.isEmpty()) { + Log.w(TAG, "openChannel: nodeId is null or empty"); + callbacks.onOpenChannelResponse(new OpenChannelResponse(ChannelStatusCodes.INVALID_ARGUMENT, null)); + return; + } + + if (path == null || path.isEmpty()) { + Log.w(TAG, "openChannel: path is null or empty"); + callbacks.onOpenChannelResponse(new OpenChannelResponse(ChannelStatusCodes.INVALID_ARGUMENT, null)); + return; + } + + AppKey appKey = getAppKey(); + boolean isReliable = true; + OpenChannelCallback openCallback = (statusCode, token, path1) -> { + try { + if (statusCode == ChannelStatusCodes.SUCCESS && token != null) { + callbacks.onOpenChannelResponse(new OpenChannelResponse(statusCode, token.toParcelable(path1))); + } else { + callbacks.onOpenChannelResponse(new OpenChannelResponse(statusCode, null)); + } + } catch (RemoteException e) { + Log.w(TAG, "Failed to send openChannel result", e); + } + }; + + channelManager.openChannel(appKey, nodeId, path, isReliable, openCallback); + } catch (Exception e) { + Log.w(TAG, "openChannel: exception during processing", e); + callbacks.onOpenChannelResponse(new OpenChannelResponse(ChannelStatusCodes.INTERNAL_ERROR, null)); + } + } + + @Override + public void closeChannel(IWearableCallbacks callbacks, String s) throws RemoteException { + closeChannelWithError(callbacks, s, 0); + } + + @Override + public void closeChannelWithError(IWearableCallbacks callbacks, String channelToken, int errorCode) throws RemoteException { + Log.d(TAG, "closeChannelWithError:" + channelToken + ", " + errorCode); + + ChannelManager channelManager = getChannelManager(); + if (channelManager == null) { + callbacks.onCloseChannelResponse(new CloseChannelResponse(ChannelStatusCodes.INTERNAL_ERROR)); + return; + } + + try { + ChannelToken token = ChannelToken.fromString(getAppKey(), channelToken); + ChannelStateMachine channel = channelManager.getChannel(token); + + if (channel == null) { + callbacks.onCloseChannelResponse(new CloseChannelResponse(ChannelStatusCodes.CHANNEL_NOT_FOUND)); + return; + } + + channelManager.closeChannel(token, errorCode); + callbacks.onCloseChannelResponse(new CloseChannelResponse(ChannelStatusCodes.SUCCESS)); + + } catch (InvalidChannelTokenException e) { + Log.w(TAG, "closeChannelWithError: invalid token", e); + callbacks.onCloseChannelResponse(new CloseChannelResponse(ChannelStatusCodes.INVALID_ARGUMENT)); + } catch (Exception e) { + Log.w(TAG, "closeChannelWithError: exception", e); + callbacks.onCloseChannelResponse(new CloseChannelResponse(ChannelStatusCodes.INTERNAL_ERROR)); + } + + } + + @Override + public void getChannelInputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String channelToken) throws RemoteException { + Log.d(TAG, "getChannelInputStream: " + channelToken); + + ChannelManager channelManager = getChannelManager(); + if (channelManager == null) { + ChannelManager.getInputStreamError(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + return; + } + + try { + ChannelToken token = ChannelToken.fromString(getAppKey(), channelToken); + ChannelStateMachine channel = channelManager.getChannel(token); + + if (channel == null) { + ChannelManager.getInputStreamError(callbacks, ChannelStatusCodes.CHANNEL_NOT_FOUND); + return; + } + + if (channel.hasInputStream()) { + ChannelManager.getInputStreamError(callbacks, ChannelStatusCodes.ALREADY_IN_PROGRESS); + return; + } + + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + ParcelFileDescriptor readEnd = pipe[0]; + ParcelFileDescriptor writeEnd = pipe[1]; + + channel.setInputStream(writeEnd, channelCallbacks); + + callbacks.onGetChannelInputStreamResponse( + new GetChannelInputStreamResponse(ChannelStatusCodes.SUCCESS, readEnd)); + + readEnd.close(); + } catch (InvalidChannelTokenException e) { + Log.w(TAG, "getChannelInputStream: invalid token", e); + ChannelManager.getInputStreamError(callbacks, ChannelStatusCodes.INVALID_ARGUMENT); + } catch (IOException e) { + Log.w(TAG, "getChannelInputStream: IO exception", e); + ChannelManager.getInputStreamError(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + } catch (Exception e) { + Log.w(TAG, "getChannelInputStream: exception", e); + ChannelManager.getInputStreamError(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + } + } + + @Override + public void getChannelOutputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String channelToken) throws RemoteException { + Log.d(TAG, "getChannelOutputStream: " + channelToken); + + ChannelManager channelManager = getChannelManager(); + if (channelManager == null) { + ChannelManager.getOutputStreamError(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + return; + } + + try { + ChannelToken token = ChannelToken.fromString(getAppKey(), channelToken); + ChannelStateMachine channel = channelManager.getChannel(token); + + if (channel == null) { + ChannelManager.getOutputStreamError(callbacks, ChannelStatusCodes.CHANNEL_NOT_FOUND); + return; + } + + if (channel.hasOutputStream()) { + ChannelManager.getOutputStreamError(callbacks, ChannelStatusCodes.ALREADY_IN_PROGRESS); + return; + } + + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + ParcelFileDescriptor readEnd = pipe[0]; + ParcelFileDescriptor writeEnd = pipe[1]; + + channel.setOutputStream(readEnd, channelCallbacks, 0, -1); + + callbacks.onGetChannelOutputStreamResponse( + new GetChannelOutputStreamResponse(ChannelStatusCodes.SUCCESS, writeEnd)); + + writeEnd.close(); + + } catch (InvalidChannelTokenException e) { + Log.w(TAG, "getChannelOutputStream: invalid token", e); + ChannelManager.getOutputStreamError(callbacks, ChannelStatusCodes.INVALID_ARGUMENT); + } catch (IOException e) { + Log.w(TAG, "getChannelOutputStream: IO exception", e); + ChannelManager.getOutputStreamError(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + } catch (Exception e) { + Log.w(TAG, "getChannelOutputStream: exception", e); + ChannelManager.getOutputStreamError(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + } + + } + + @Override + public void writeChannelInputToFd(IWearableCallbacks callbacks, String channelToken, ParcelFileDescriptor fd) throws RemoteException { + Log.d(TAG, "writeChannelInputToFd: " + channelToken); + + ChannelManager channelManager = getChannelManager(); + if (channelManager == null) { + ChannelManager.receiveFileResult(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + return; + } + + try { + ChannelToken token = ChannelToken.fromString(getAppKey(), channelToken); + ChannelStateMachine channel = channelManager.getChannel(token); + + if (channel == null) { + ChannelManager.receiveFileResult(callbacks, ChannelStatusCodes.CHANNEL_NOT_FOUND); + return; + } + + if (channel.hasInputStream()) { + ChannelManager.receiveFileResult(callbacks, ChannelStatusCodes.ALREADY_IN_PROGRESS); + return; + } + + channel.setInputStream(fd, new ReceiveFileStreamCallback(callbacks)); + + } catch (InvalidChannelTokenException e) { + Log.w(TAG, "writeChannelInputToFd: invalid token", e); + ChannelManager.receiveFileResult(callbacks, ChannelStatusCodes.INVALID_ARGUMENT); + } catch (Exception e) { + Log.w(TAG, "writeChannelInputToFd: exception", e); + ChannelManager.receiveFileResult(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + } + + } + + @Override + public void readChannelOutputFromFd(IWearableCallbacks callbacks, String channelToken, ParcelFileDescriptor fd, long startOffset, long length) throws RemoteException { + Log.d(TAG, "unimplemented Method: readChannelOutputFromFd: " + channelToken + ", " + startOffset + ", " + length); + + ChannelManager channelManager = getChannelManager(); + if (channelManager == null) { + ChannelManager.sendFileResult(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + return; + } + + try { + ChannelToken token = ChannelToken.fromString(getAppKey(), channelToken); + ChannelStateMachine channel = channelManager.getChannel(token); + + if (channel == null) { + ChannelManager.sendFileResult(callbacks, ChannelStatusCodes.CHANNEL_NOT_FOUND); + return; + } + + if (channel.hasOutputStream()) { + ChannelManager.sendFileResult(callbacks, ChannelStatusCodes.ALREADY_IN_PROGRESS); + return; + } + + channel.setOutputStream(fd, new SendFileStreamCallback(callbacks), startOffset, length); + + } catch (InvalidChannelTokenException e) { + Log.w(TAG, "readChannelOutputFromFd: invalid token", e); + ChannelManager.sendFileResult(callbacks, ChannelStatusCodes.INVALID_ARGUMENT); + } catch (Exception e) { + Log.w(TAG, "readChannelOutputFromFd: exception", e); + ChannelManager.sendFileResult(callbacks, ChannelStatusCodes.INTERNAL_ERROR); + } + + } + + private static class ReceiveFileStreamCallback extends IChannelStreamCallbacks.Stub { + private final IWearableCallbacks callbacks; + + ReceiveFileStreamCallback(IWearableCallbacks callbacks) { + this.callbacks = callbacks; + } + + @Override + public void onChannelClosed(int closeReason, int errorCode) throws RemoteException { + int statusCode = (closeReason == ChannelStatusCodes.CLOSE_REASON_NORMAL) + ? ChannelStatusCodes.SUCCESS : closeReason; + ChannelManager.receiveFileResult(callbacks, statusCode); + } + } + + private static class SendFileStreamCallback extends IChannelStreamCallbacks.Stub { + private final IWearableCallbacks callbacks; + + SendFileStreamCallback(IWearableCallbacks callbacks) { + this.callbacks = callbacks; + } + + @Override + public void onChannelClosed(int closeReason, int errorCode) throws RemoteException { + int statusCode = (closeReason == ChannelStatusCodes.CLOSE_REASON_NORMAL) + ? ChannelStatusCodes.SUCCESS : closeReason; + ChannelManager.sendFileResult(callbacks, statusCode); + } + } + + private static void deliver(IWearableCallbacks callbacks, Status status) { + try { + callbacks.onStatus(status); + } catch (RemoteException e) { + Log.d(TAG, "Failed to deliver result to app", e); + } + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + @Override + public void syncWifiCredentials(IWearableCallbacks callbacks) throws RemoteException { + postNetwork(callbacks, () -> { + WearableWifiService service = wearable.getWearWifiService(); + if (service == null) { + Log.e(TAG, "syncWifiCredentials: wifiService is null!"); + deliver(callbacks, new Status(CommonStatusCodes.INTERNAL_ERROR)); + return; + } + try { + deliver(callbacks, service.syncCredentials(true) + ? Status.SUCCESS + : new Status(WearableStatusCodes.WIFI_CREDENTIAL_SYNC_NO_CREDENTIAL_FETCHED)); + } catch (RuntimeException e) { + Log.e(TAG, "syncWifiCredentials: exception during processing", e); + deliver(callbacks, new Status(CommonStatusCodes.INTERNAL_ERROR)); + } + }); + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + @Override + public void syncWifiCredentialWithSsid(IWearableCallbacks callbacks, String nodeId, String ssid) throws RemoteException { + postNetwork(callbacks, () -> { + WearableWifiService service = wearable.getWearWifiService(); + if (service == null) { + Log.e(TAG, "syncWifiCredential: wifiService is null!"); + deliver(callbacks, new Status(CommonStatusCodes.INTERNAL_ERROR)); + return; + } + try { + service.syncCredentialToNode(nodeId, ssid, status -> deliver(callbacks, status)); + } catch (RuntimeException e) { + Log.e(TAG, "syncWifiCredential: exception during processing", e); + deliver(callbacks, new Status(CommonStatusCodes.INTERNAL_ERROR)); + } + }); + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + @Override + public void syncWifiCredentialForNode(IWearableCallbacks callbacks, String nodeId) throws RemoteException { + postNetwork(callbacks, () -> { + WearableWifiService service = wearable.getWearWifiService(); + if (service == null) { + Log.e(TAG, "syncConnectedWifiCredential: wifiService is null!"); + deliver(callbacks, new Status(CommonStatusCodes.INTERNAL_ERROR)); + return; + } + try { + service.syncConnectedCredentialToNode(nodeId, status -> deliver(callbacks, status)); + } catch (RuntimeException e) { + Log.e(TAG, "syncConnectedWifiCredential: exception during processing", e); + deliver(callbacks, new Status(CommonStatusCodes.INTERNAL_ERROR)); + } + }); + } + + /* + * Connection deprecated + */ + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + @Override + @Deprecated + public void putConnection(IWearableCallbacks callbacks, ConnectionConfiguration config) throws RemoteException { + Log.d(TAG, "putConnection: " + config.name); + config.packageName = this.packageName; + postMain(callbacks, () -> { + wearable.createConnection(config); + wearable.enableConnection(config.name); + callbacks.onStatus(Status.SUCCESS); + }); } @Override @@ -447,6 +2419,7 @@ public void getConnection(IWearableCallbacks callbacks) throws RemoteException { }); } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) @Override @Deprecated public void enableConnection(IWearableCallbacks callbacks) throws RemoteException { @@ -458,6 +2431,7 @@ public void enableConnection(IWearableCallbacks callbacks) throws RemoteExceptio }); } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) @Override @Deprecated public void disableConnection(IWearableCallbacks callbacks) throws RemoteException { diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableTerms.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableTerms.java new file mode 100644 index 0000000000..9a6328410d --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableTerms.java @@ -0,0 +1,84 @@ +package org.microg.gms.wearable; + +import android.content.Context; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public final class WearableTerms { + public static final int CONTEXT_UNSUPERVISED = 0; + public static final int CONTEXT_SUPERVISED = 1; + + public static final int TOS = 0; + public static final int LOGGING = 1; + public static final int CLOUDSYNC = 2; + public static final int LOCATION = 3; + public static final int UPDATES = 4; + public static final int BACKUP = 5; + + public static final class TermDef { + public final int termType; + public final boolean explicit; + private final String name; + private final String fallbackTitle; + private final String fallbackDescription; + + TermDef(int termType, String name, boolean explicit, String fallbackTitle, String fallbackDescription) { + this.termType = termType; + this.name = name; + this.explicit = explicit; + this.fallbackTitle = fallbackTitle; + this.fallbackDescription = fallbackDescription; + } + + public String getTitle(Context context) { + return lookup(context, "wearable_tos_" + name + "_title", fallbackTitle); + } + + public String getDescription(Context context) { + return lookup(context, "wearable_tos_" + name + "_description", fallbackDescription); + } + + public int getOptInType() { + return (termType == LOGGING || termType == CLOUDSYNC || termType == LOCATION) ? termType : 0; + } + + private static String lookup(Context context, String resName, String fallback) { + int id = context.getResources().getIdentifier(resName, "string", context.getPackageName()); + return id != 0 ? context.getString(id) : fallback; + } + } + + private static final TermDef T_TOS = new TermDef(TOS, "tos", false, + "Set up your watch", + "Your watch connects to this phone through microG. The choices below are saved on this phone and shared with your watch. You can change them later."); + private static final TermDef T_LOCATION = new TermDef(LOCATION, "location", true, + "Location", + "Let your watch use this phone's location, for example for weather, maps and activity tracking."); + private static final TermDef T_LOGGING = new TermDef(LOGGING, "logging", true, + "Diagnostics", + "Allow diagnostic data from your watch to be collected to help improve its software."); + private static final TermDef T_BACKUP = new TermDef(BACKUP, "backup", true, + "Backup", + "Back up your watch's data with your account so it can be restored on a new watch."); + private static final TermDef T_UPDATES = new TermDef(UPDATES, "updates", false, + "Updates", + "Your watch may receive system and app updates."); + private static final TermDef T_CLOUDSYNC = new TermDef(CLOUDSYNC, "cloudsync", true, + "Wi-Fi and cloud sync", + "Let your watch sync over Wi-Fi and mobile networks when it is not connected to this phone."); + + public static List forContext(int termsContext) { + switch (termsContext) { + case CONTEXT_UNSUPERVISED: + return Collections.unmodifiableList(Arrays.asList(T_TOS, T_LOCATION, T_LOGGING, T_BACKUP, T_UPDATES, T_CLOUDSYNC)); + case CONTEXT_SUPERVISED: + return Collections.unmodifiableList(Arrays.asList(T_TOS, T_LOCATION, T_LOGGING, T_UPDATES)); + default: + return null; + } + } + + private WearableTerms() {} +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableWriter.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableWriter.java new file mode 100644 index 0000000000..59f17292ee --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableWriter.java @@ -0,0 +1,141 @@ +package org.microg.gms.wearable; + +import android.util.Log; + +import org.microg.gms.wearable.proto.Heartbeat; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class WearableWriter { + private static final String TAG = "GmsWearWriter"; + + private static final RootMessage STOP = new RootMessage.Builder().build(); + + private final String nodeId; + private final WearableConnection connection; + private final LinkedBlockingDeque queue = new LinkedBlockingDeque<>(); + private final CountDownLatch finishedLatch = new CountDownLatch(1); + private final AtomicBoolean closed = new AtomicBoolean(false); + + private volatile Thread thread; + + private static final RootMessage HEARTBEAT_REQUEST = new RootMessage.Builder().build(); + private static final RootMessage HEARTBEAT_OUTGOING = new RootMessage.Builder() + .heartbeat(new Heartbeat.Builder().build()).build(); + private final AtomicBoolean heartbeatPending = new AtomicBoolean(false); + + public WearableWriter(String nodeId, WearableConnection connection) { + this.nodeId = nodeId; + this.connection = connection; + } + + public void start() { + thread = new Thread(this::loop, "WearWriter-" + nodeId); + thread.start(); + Log.d(TAG, "Writer started for node " + nodeId); + } + + public void close() { + if (closed.compareAndSet(false, true)) { + Log.d(TAG, "Closing writer for node " + nodeId); + queue.clear(); + queue.offer(STOP); + Thread t = thread; + if (t != null) t.interrupt(); + } + } + + public boolean enqueue(RootMessage message) { + if (!closed.get()) { + return queue.offer(message); + } + return false; + } + + public void requestHeartbeat() { + if (closed.get()) + return; + + if (heartbeatPending.compareAndSet(false, true)) + queue.offer(HEARTBEAT_REQUEST); + } + + public boolean awaitFinished(long timeout, TimeUnit unit) throws InterruptedException { + return finishedLatch.await(timeout, unit); + } + + public void awaitFinished() { + boolean interrupted = false; + while (true) { + try { + finishedLatch.await(); + break; + } catch (InterruptedException e){ + interrupted = true; + } + } + if (interrupted) Thread.currentThread().interrupt(); + } + + public boolean isClosed() { + return closed.get(); + } + + private void loop() { + try { + while (!Thread.currentThread().isInterrupted()) { + RootMessage message; + try { + message = queue.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + + if (message == STOP) break; + + if (message == HEARTBEAT_REQUEST) { + heartbeatPending.set(false); + if (queue.isEmpty()) { + try { + connection.writeMessage(HEARTBEAT_OUTGOING); + } catch (IOException e) { + if (!closed.get()) { + Log.w(TAG, "Heartbeat write failed for node " + nodeId + ": " + e.getMessage()); + try { + connection.close(); + } catch (IOException ignore) {} + } + break; + } + } + continue; + } + + try { + connection.writeMessage(message); + } catch (IOException e) { + if (!closed.get()) { + Log.w(TAG, "Write failed for node " + nodeId + ": " + e.getMessage()); + try { + connection.close(); + } catch (IOException ignore) {} + } + break; + } + } + } catch (Exception e) { + if (!closed.get()) { + Log.e(TAG, "Unexpected writer error for node " + nodeId, e); + } + } finally { + Log.d(TAG, "Writer finished for node "+ nodeId); + finishedLatch.countDown(); + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/AlarmManagerHelper.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/AlarmManagerHelper.java new file mode 100644 index 0000000000..f4b82b6e65 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/AlarmManagerHelper.java @@ -0,0 +1,105 @@ +package org.microg.gms.wearable.bluetooth; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.os.Build; +import android.os.SystemClock; +import android.util.Log; + +public class AlarmManagerHelper { + private static final String TAG = "AlarmManagerHelper"; + + private final Context context; + private final AlarmManager alarmManager; + + public AlarmManagerHelper(Context context) { + this.context = context; + this.alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + if (alarmManager == null) { + throw new IllegalStateException("AlarmManager not available"); + } + } + + public void setExactAndAllowWhileIdle(String tag, int type, long triggerAtMillis, + PendingIntent operation) { + if (triggerAtMillis <= 0) { + Log.w(TAG, String.format("Invalid trigger time: %d", triggerAtMillis)); + return; + } + + long delayMs = triggerAtMillis - SystemClock.elapsedRealtime(); + Log.d(TAG, String.format("setExactAndAllowWhileIdle: tag=%s, type=%d, delay=%dms", + tag, type, delayMs)); + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle(type, triggerAtMillis, operation); + } else { + alarmManager.setExact(type, triggerAtMillis, operation); + } + } catch (SecurityException e) { + Log.e(TAG, "SecurityException setting alarm", e); + throw e; + } catch (Exception e) { + Log.e(TAG, "Error setting alarm", e); + throw new RuntimeException("Failed to set alarm", e); + } + } + + public void setWindow(String tag, int type, long triggerAtMillis, long windowMs, + PendingIntent operation) { + if (triggerAtMillis <= 0) { + Log.w(TAG, String.format("Invalid trigger time: %d", triggerAtMillis)); + return; + } + + long delayMs = triggerAtMillis - SystemClock.elapsedRealtime(); + Log.d(TAG, String.format("setWindow: tag=%s, type=%d, delay=%dms, window=%dms", + tag, type, delayMs, windowMs)); + + try { + alarmManager.setWindow(type, triggerAtMillis, windowMs, operation); + } catch (Exception e) { + Log.e(TAG, "Error setting windowed alarm", e); + throw new RuntimeException("Failed to set windowed alarm", e); + } + } + + public void cancel(PendingIntent operation) { + try { + alarmManager.cancel(operation); + Log.d(TAG, "Cancelled alarm"); + } catch (Exception e) { + Log.w(TAG, "Error cancelling alarm", e); + } + } + + public static PendingIntent createPendingIntent(Context context, int requestCode, + android.content.Intent intent) { + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + + return PendingIntent.getBroadcast(context, requestCode, intent, flags); + } + + public static long elapsedRealtimeFromNow(long delayMs) { + return SystemClock.elapsedRealtime() + delayMs; + } + + public boolean canScheduleExactAlarms() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + return alarmManager.canScheduleExactAlarms(); + } catch (Exception e) { + Log.w(TAG, "Error checking exact alarm permission", e); + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleConnectionManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleConnectionManager.java new file mode 100644 index 0000000000..6233920889 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleConnectionManager.java @@ -0,0 +1,908 @@ +package org.microg.gms.wearable.bluetooth; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGattCharacteristic; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; +import android.os.Looper; +import android.os.Message; +import android.util.Log; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +public class BleConnectionManager extends BleStateMachine implements BleConnectionManagerInterface, GattEventListener { + private static final String TAG = "BleConnectionManager"; + + public static final int MSG_INIT = 1; + public static final int MSG_BT_ADAPTER_STATE_CHANGED = 2; + public static final int MSG_CONNECTION_CONFIG_UPDATE = 3; + public static final int MSG_START_SCAN = 4; + public static final int MSG_START_FORCED_SCAN = 5; + public static final int MSG_SCAN_FAILED = 6; + public static final int MSG_STOP_SCAN = 7; + public static final int MSG_RESCHEDULE_SCAN = 8; + public static final int MSG_RECONNECT_REQUESTED = 9; + public static final int MSG_SERVICE_DISCOVERY_COMPLETE = 10; + public static final int MSG_HANDLE_NOTIFICATION = 11; + public static final int MSG_DECOMMISSION_WATCH = 12; + public static final int MSG_RECONNECT_CHARACTERISTIC_CHANGED = 13; + public static final int MSG_ERROR = 14; + public static final int MSG_CONNECTION_THREAD_DONE = 15; + public static final int MSG_GATT_CONNECTION_CLOSED = 16; + public static final int MSG_READY_TO_SETUP_ANCS = 17; + public static final int MSG_UPDATE_TIME = 18; + public static final int MSG_ON_SERVICE_CHANGED = 19; + public static final int MSG_RESET_CHARACTERICTIC_CHANGED = 20; + public static final int MSG_RESET_CONNECTION = 21; + private static final int MSG_GATT_CONNECTED = 22; + + private static final long SCAN_TIMEOUT_MS = 30000L; + + public final Context context; + public final BluetoothAdapter btAdapter; + + public final BleServicesHandler gattHelper; + final BluetoothGattHelper bleConnHelper; + private final BleScanner bleScanner; + + public volatile AtomicReference config; + + final AtomicBoolean isReceiverRegistered = new AtomicBoolean(false); + public final AtomicInteger scanAttemptCount = new AtomicInteger(); + public final AtomicInteger totalExceptionCount = new AtomicInteger(); + public final AtomicInteger disconnectExceptionCount = new AtomicInteger(); + + final AtomicLong timeServiceNotFoundCounter = new AtomicLong(); + final AtomicLong invalidGattHandleCounter = new AtomicLong(); + final AtomicLong readNotPermittedCounter = new AtomicLong(); + final AtomicLong writeNotPermittedCounter = new AtomicLong(); + final AtomicLong invalidDecommissionCounter = new AtomicLong(); + final AtomicLong serviceNotFoundCounter = new AtomicLong(); + final AtomicLong timeCharInvalidCounter = new AtomicLong(); + final AtomicLong timezoneOffsetInvalidCounter = new AtomicLong(); + final AtomicLong missingClockworkCharCounter = new AtomicLong(); + + private final AtomicLong errorSampler = new AtomicLong(); + private final AtomicBoolean isBtlePrioEnabled = new AtomicBoolean(true); + + final BroadcastReceiver btStateReceiver; + public final BroadcastReceiver screenOnReceiver; + + final BleState idleState; + public final BleState discoveredState; + public final BleState connectedState; + public final BleState disconnectingState; + public final BleState errorDisconnectedState; + private final BleState disconnectedState; + private final BleState scanningState; + private final BleState connectingState; + + public BleConnectionManager( + Context context, + BluetoothAdapter bluetoothAdapter, + BleScanner bleScanner, + BluetoothGattHelper bleConnHelper, + BleServicesHandler gattHelper, + Looper looper, + ConnectionConfiguration connectionConfiguration) { + super("BleConnectionManager", looper); + + this.context = context; + this.btAdapter = bluetoothAdapter; + this.bleScanner = bleScanner; + this.bleConnHelper = bleConnHelper; + this.gattHelper = gattHelper; + this.config = new AtomicReference<>(); + + bleConnHelper.setGattEventListener(this); + + this.btStateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + ConnectionConfiguration cfg = BleConnectionManager.this.config.get(); + if (cfg == null || !cfg.enabled) return; + + String action = intent.getAction(); + if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) { + int state = intent.getIntExtra( + BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF); + if (state == BluetoothAdapter.STATE_OFF + || state == BluetoothAdapter.STATE_TURNING_OFF) { + sendBtAdapterStateMsg(state); + } + } else if ("android.gms.wearable.altReconnect".equals(action)) { + sendMessage(MSG_RECONNECT_REQUESTED); + } + } + }; + + this.screenOnReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + removeMessages(MSG_START_FORCED_SCAN); + sendMessage(MSG_START_FORCED_SCAN); + BleConnectionManager.this.context + .unregisterReceiver(BleConnectionManager.this.screenOnReceiver); + } + }; + + BleState idleStateLocal = new IdleState(this); + BleState disconnectedStateLocal = new ServiceOnState(this); + BleState discoveredStateLocal = new DisconnectedState(this); + BleState scanningStateLocal = new ScanningState(this); + BleState connectingStateLocal = new ConnectingState(this); + BleState connectedStateLocal = new ConnectedState(this); + BleState disconnectingStateLocal = new DisconnectingState(this); + BleState errorDisconnectedStateLocal = new ServiceOffState(this); + + this.idleState = idleStateLocal; + this.disconnectedState = disconnectedStateLocal; + this.discoveredState = discoveredStateLocal; + this.scanningState = scanningStateLocal; + this.connectingState = connectingStateLocal; + this.connectedState = connectedStateLocal; + this.disconnectingState = disconnectingStateLocal; + this.errorDisconnectedState = errorDisconnectedStateLocal; + + this.isBtlePrioEnabled.set( + connectionConfiguration == null || connectionConfiguration.btlePriority); + + this.config.set(connectionConfiguration); + + addState(disconnectedStateLocal); + addState(discoveredStateLocal); + addState(scanningStateLocal); + addState(connectingStateLocal); + addState(connectedStateLocal); + addState(disconnectingStateLocal); + addState(errorDisconnectedStateLocal); + + addTransition(disconnectedStateLocal, discoveredStateLocal); + addTransition(discoveredStateLocal, scanningStateLocal); + addTransition(scanningStateLocal, connectingStateLocal); + addTransition(scanningStateLocal, disconnectingStateLocal); + addTransition(connectingStateLocal, connectedStateLocal); + addTransition(connectingStateLocal, disconnectingStateLocal); + addTransition(connectedStateLocal, disconnectingStateLocal); + addTransition(disconnectingStateLocal, discoveredStateLocal); + addTransition(discoveredStateLocal, disconnectedStateLocal); + addTransition(disconnectedStateLocal, errorDisconnectedStateLocal); + addTransition(errorDisconnectedStateLocal, disconnectedStateLocal); + + setErrorState(errorDisconnectedStateLocal); + start(); + } + + @Override + protected String getMessageName(int what) { + switch (what) { + case MSG_INIT: return "MSG_INIT"; + case MSG_BT_ADAPTER_STATE_CHANGED: return "MSG_BT_ADAPTER_STATE_CHANGED"; + case MSG_CONNECTION_CONFIG_UPDATE: return "MSG_CONNECTION_CONFIG_UPDATE"; + case MSG_START_SCAN: return "MSG_START_SCAN"; + case MSG_START_FORCED_SCAN: return "MSG_START_FORCED_SCAN"; + case MSG_SCAN_FAILED: return "MSG_SCAN_FAILED"; + case MSG_STOP_SCAN: return "MSG_STOP_SCAN"; + case MSG_RESCHEDULE_SCAN: return "MSG_RESCHEDULE_SCAN"; + case MSG_RECONNECT_REQUESTED: return "MSG_RECONNECT_REQUESTED"; + case MSG_SERVICE_DISCOVERY_COMPLETE: return "MSG_SERVICE_DISCOVERY_COMPLETE"; + case MSG_HANDLE_NOTIFICATION: return "MSG_HANDLE_NOTIFICATION"; + case MSG_DECOMMISSION_WATCH: return "MSG_DECOMMISSION_WATCH"; + case MSG_RECONNECT_CHARACTERISTIC_CHANGED: return "MSG_RECONNECT_CHARACTERISTIC_CHANGED"; + case MSG_ERROR: return "MSG_ERROR"; + case MSG_CONNECTION_THREAD_DONE: return "MSG_CONNECTION_THREAD_DONE"; + case MSG_GATT_CONNECTION_CLOSED: return "MSG_GATT_CONNECTION_CLOSED"; + case MSG_READY_TO_SETUP_ANCS: return "MSG_READY_TO_SETUP_ANCS"; + case MSG_UPDATE_TIME: return "MSG_UPDATE_TIME"; + case MSG_ON_SERVICE_CHANGED: return "MSG_ON_SERVICE_CHANGED"; + case MSG_RESET_CHARACTERICTIC_CHANGED: return "MSG_RESET_CHARACTERICTIC_CHANGED"; + case MSG_RESET_CONNECTION: return "MSG_RESET_CONNECTION"; + case MSG_GATT_CONNECTED: return "MSG_GATT_CONNECTED"; + default: return "UNKNOWN"; + } + } + + @Override + public void updateConfiguration(ConnectionConfiguration connectionConfiguration) { + Log.d(TAG, "updateConfiguration: config is " + + (connectionConfiguration.enabled ? "enabled" : "disabled")); + this.config.set(connectionConfiguration); + sendMessage(MSG_CONNECTION_CONFIG_UPDATE); + } + + @Override + public void quit() { + + } + + @Override + public void quitSafely() { + + } + + @Override + public void onGattConnected() { + Log.d(TAG, "onGattConnected"); + sendMessage(MSG_GATT_CONNECTED); + } + + @Override + public void onServiceChanged() { + Log.d(TAG, "onServiceChanged"); + sendMessage(MSG_ON_SERVICE_CHANGED); + } + + @Override + public void onCharacteristicWritten(BluetoothGattCharacteristic characteristic) { + characteristic.getUuid(); + } + + @Override + public void onServicesDiscovered() { + Log.d(TAG, "onServicesDiscovered"); + sendMessage(MSG_SERVICE_DISCOVERY_COMPLETE); + } + + @Override + public void onCharacteristicChanged(BluetoothGattCharacteristic characteristic) {} + + @Override + protected boolean shouldLogMessage(Message message) { + return message.what != MSG_HANDLE_NOTIFICATION; + } + + protected void onQuitting() { + Log.d(TAG, "onQuitting"); + stopScan(); + doDisconnectGatt(); + doDisconnectGatt(); // twice in original GMS + gattHelper.cleanup(); + if (isReceiverRegistered.compareAndSet(true, false)) { + context.unregisterReceiver(btStateReceiver); + } + } + + void syncReceiverRegistration() { + ConnectionConfiguration cfg = config.get(); + if (cfg != null && !cfg.enabled) { + if (isReceiverRegistered.compareAndSet(true, false)) { + context.unregisterReceiver(btStateReceiver); + } + } else if (isReceiverRegistered.compareAndSet(false, true)) { + IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED); + filter.addAction("android.gms.wearable.altReconnect"); + context.registerReceiver(btStateReceiver, filter); + } + } + + boolean handleUnhandledMessage(Message message) { + int what = message.what; + if (what == MSG_RECONNECT_REQUESTED || what == MSG_CONNECTION_THREAD_DONE) { + return true; + } + Log.d(TAG, "[" + currentState().getName() + "] Unhandled message: " + message.what); + return false; + } + + void stopScan() { + if (!bleScanner.isScanning()) { + Log.d(TAG, "Not scanning, returning."); + return; + } + bleScanner.stopScan(); + removeMessages(MSG_START_SCAN); + removeMessages(MSG_STOP_SCAN); + removeMessages(MSG_START_FORCED_SCAN); + Log.d(TAG, "Stopped scan."); + } + + private void doDisconnectGatt() { + try { + try { + if (bleConnHelper.isConnected()) { + Log.d(TAG, "Disconnecting"); + bleConnHelper.disconnect(); + } else { + Log.d(TAG, "Not disconnecting; already disconnected"); + } + } catch (BleException e) { + Log.w(TAG, "Bluetooth exception caught while disconnecting"); + } + } finally { + sendMessageDelayed(MSG_GATT_CONNECTION_CLOSED, 0); + } + } + + void handleBleException(BleException e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + Log.w(TAG, "Got exception: " + sw, e); + totalExceptionCount.incrementAndGet(); + + int code = e.statusCode; + + if (code == BleException.CODE_MISSING_CLOCKWORK_CHARS) { + if (Build.VERSION.SDK_INT >= 28) { + Log.d(TAG, "Clockwork service characteristics are missing."); + return; + } else { + bleConnHelper.refreshGatt(); + missingClockworkCharCounter.incrementAndGet(); + return; + } + } + + disconnectExceptionCount.incrementAndGet(); + + if (code == BleException.CODE_GATT_INVALID_HANDLE + || code == BleException.CODE_GATT_READ_NOT_PERMITTED + || code == BleException.CODE_GATT_WRITE_NOT_PERMITTED + || code == BleException.CODE_INVALID_DECOMMISSION + || code == BleException.CODE_TIMEZONE_OFFSET_INVALID + || code == BleException.CODE_TIME_CHAR_INVALID) { + bleConnHelper.refreshGatt(); + incrementExceptionCounter(code); + return; + } + + if (code == BleException.CODE_TIME_SERVICE_NOT_FOUND + || code == BleException.CODE_SERVICE_NOT_FOUND) { + if (Build.VERSION.SDK_INT >= 28) { + Log.d(TAG, "Service is missing when OnServiceChanged enabled."); + } else { + bleConnHelper.refreshGatt(); + incrementExceptionCounter(code); + } + return; + } + + if (e instanceof BleTimeoutException) { + errorSampler.incrementAndGet(); + return; + } + + if (code != BleException.CODE_UNKNOWN) { + errorSampler.incrementAndGet(); + } else { + Log.w(TAG, "Unable to log unhandled exception: " + e); + } + } + + + private void incrementExceptionCounter(int code) { + switch (code) { + case BleException.CODE_GATT_INVALID_HANDLE: + invalidGattHandleCounter.incrementAndGet(); break; + case BleException.CODE_GATT_READ_NOT_PERMITTED: + readNotPermittedCounter.incrementAndGet(); break; + case BleException.CODE_GATT_WRITE_NOT_PERMITTED: + writeNotPermittedCounter.incrementAndGet(); break; + case BleException.CODE_TIME_SERVICE_NOT_FOUND: + timeServiceNotFoundCounter.incrementAndGet(); break; + case BleException.CODE_MISSING_CLOCKWORK_CHARS: + missingClockworkCharCounter.incrementAndGet(); break; + case BleException.CODE_INVALID_DECOMMISSION: + invalidDecommissionCounter.incrementAndGet(); break; + case BleException.CODE_SERVICE_NOT_FOUND: + serviceNotFoundCounter.incrementAndGet(); break; + case BleException.CODE_TIME_CHAR_INVALID: + timeCharInvalidCounter.incrementAndGet(); break; + case BleException.CODE_TIMEZONE_OFFSET_INVALID: + timezoneOffsetInvalidCounter.incrementAndGet(); break; + default: + Log.w(TAG, "Failed to log exception with status code: " + code); + break; + } + } + + static final class IdleState extends BleState { + private final BleConnectionManager mgr; + + IdleState(BleConnectionManager mgr) { + this.mgr = mgr; + } + + @Override + public String getName() { return "IdleState"; } + + @Override + public boolean handleMessage(Message msg) { + return true; + } + } + + static final class ServiceOnState extends BleState { + private final BleConnectionManager mgr; + + ServiceOnState(BleConnectionManager mgr) { + this.mgr = mgr; + } + + @Override + public String getName() { return "ServiceOnState"; } + + @Override + public void onEnter() { + mgr.syncReceiverRegistration(); + ConnectionConfiguration cfg = mgr.config.get(); + if (cfg == null || !cfg.enabled) { + mgr.sendMessage(MSG_CONNECTION_CONFIG_UPDATE); + } else if (mgr.btAdapter.getState() == BluetoothAdapter.STATE_ON) { + mgr.sendBtAdapterStateMsg(BluetoothAdapter.STATE_ON); + } + } + + @Override + public boolean handleMessage(Message msg) { + ConnectionConfiguration cfg = mgr.config.get(); + boolean enabled = cfg != null && cfg.enabled; + switch (msg.what) { + case MSG_BT_ADAPTER_STATE_CHANGED: + if (msg.arg1 == BluetoothAdapter.STATE_ON && enabled) { + mgr.transitionTo(mgr.discoveredState); + } + return true; + case MSG_CONNECTION_CONFIG_UPDATE: + mgr.syncReceiverRegistration(); + if (!enabled) { + mgr.transitionTo(mgr.errorDisconnectedState); + return true; + } + if (mgr.btAdapter.getState() == BluetoothAdapter.STATE_ON) { + mgr.transitionTo(mgr.discoveredState); + } + return true; + case MSG_DECOMMISSION_WATCH: + mgr.transitionTo(mgr.errorDisconnectedState); + return true; + default: + return mgr.handleUnhandledMessage(msg); + } + } + } + + static final class DisconnectedState extends BleState { + private final BleConnectionManager mgr; + + DisconnectedState(BleConnectionManager mgr) { + this.mgr = mgr; + } + + @Override + public String getName() { + return "DisconnectedState"; + } + + @Override + public void onEnter() { + ConnectionConfiguration cfg = mgr.config.get(); + if (cfg == null || !cfg.enabled) { + mgr.sendMessage(MSG_CONNECTION_CONFIG_UPDATE); + } else if (mgr.btAdapter.getState() == BluetoothAdapter.STATE_ON) { + mgr.sendMessage(MSG_INIT); + } else if (mgr.btAdapter.getState() == BluetoothAdapter.STATE_OFF) { + mgr.sendBtAdapterStateMsg(BluetoothAdapter.STATE_OFF); + } + } + + @Override + public boolean handleMessage(Message msg) { + ConnectionConfiguration cfg = mgr.config.get(); + boolean enabled = cfg != null && cfg.enabled; + switch (msg.what) { + case MSG_INIT: + mgr.transitionTo(mgr.scanningState); + return true; + case MSG_BT_ADAPTER_STATE_CHANGED: + if (msg.arg1 == BluetoothAdapter.STATE_OFF) { + mgr.transitionTo(mgr.disconnectedState); + } else if (msg.arg1 == BluetoothAdapter.STATE_ON && enabled) { + mgr.transitionTo(mgr.scanningState); + } + return true; + case MSG_CONNECTION_CONFIG_UPDATE: + if (!enabled) { + mgr.transitionTo(mgr.disconnectedState); + return true; + } + if (mgr.btAdapter.getState() == BluetoothAdapter.STATE_ON) { + mgr.transitionTo(mgr.scanningState); + } + return true; + + default: + return mgr.handleUnhandledMessage(msg); + } + } + } + + static final class ScanningState extends BleState { + private final BleConnectionManager mgr; + + ScanningState(BleConnectionManager mgr) { + this.mgr = mgr; + } + + @Override + public String getName() { + return "ScanningState"; + } + + @Override + public void onEnter() { + mgr.sendMessage(MSG_START_SCAN); + } + + @Override + public void onExit() { + mgr.stopScan(); + } + + @Override + public boolean handleMessage(Message msg) { + ConnectionConfiguration cfg = mgr.config.get(); + switch (msg.what) { + case MSG_START_SCAN: { + if (cfg == null || !cfg.enabled) { + mgr.transitionTo(mgr.disconnectingState); + return true; + } + String addr = cfg.address; + if (addr == null) { + Log.w(TAG, "ScanningState: no bluetooth address in config, cannot scan"); + mgr.transitionTo(mgr.disconnectingState); + return true; + } + + Log.d(TAG, "ScanningState: starting scan for " + addr); + mgr.bleScanner.startScan(addr, new BleScanner.ScanListener() { + @Override + public void onDeviceFound(String address) { + Log.d(TAG, "BLE device found: " + address); + mgr.removeMessages(MSG_START_SCAN); + mgr.removeMessages(MSG_STOP_SCAN); + mgr.sendMessage(MSG_START_FORCED_SCAN); + } + + @Override + public void onScanFailed(int errorCode) { + Log.w(TAG, "BLE scan failed, errorCode=" + errorCode); + mgr.sendMessage(MSG_SCAN_FAILED); + } + }); + + mgr.sendMessageDelayed(MSG_STOP_SCAN, SCAN_TIMEOUT_MS); + return true; + } + + case MSG_START_FORCED_SCAN: { + mgr.stopScan(); + if (cfg == null) { + mgr.transitionTo(mgr.disconnectingState); + return true; + } + String addr = cfg.address; + if (addr == null) { + Log.w(TAG, "ScanningState: device found but address is null"); + mgr.transitionTo(mgr.disconnectingState); + return true; + } + BluetoothDevice device = mgr.btAdapter.getRemoteDevice(addr); + Log.d(TAG, "ScanningState: connecting GATT to " + addr); + mgr.bleConnHelper.connect(device); + return true; + } + + case MSG_GATT_CONNECTED: + Log.d(TAG, "ScanningState: GATT connected, transitioning to ConnectingState"); + mgr.transitionTo(mgr.connectingState); + return true; + + case MSG_STOP_SCAN: + Log.d(TAG, "ScanningState: scan timeout"); + mgr.stopScan(); + mgr.transitionTo(mgr.disconnectingState); + return true; + + case MSG_SCAN_FAILED: + Log.w(TAG, "ScanningState: scan failed"); + mgr.stopScan(); + mgr.transitionTo(mgr.disconnectingState); + return true; + + case MSG_RESCHEDULE_SCAN: + mgr.stopScan(); + mgr.sendMessage(MSG_START_SCAN); + return true; + + case MSG_CONNECTION_CONFIG_UPDATE: { + boolean enabled = cfg != null && cfg.enabled; + if (!enabled) { + mgr.transitionTo(mgr.disconnectingState); + } + return true; + } + + case MSG_BT_ADAPTER_STATE_CHANGED: + if (msg.arg1 == BluetoothAdapter.STATE_OFF) { + mgr.transitionTo(mgr.disconnectingState); + } + return true; + + case MSG_GATT_CONNECTION_CLOSED: + mgr.transitionTo(mgr.disconnectingState); + return true; + + default: + return mgr.handleUnhandledMessage(msg); + } + } + } + + static final class ConnectingState extends BleState { + private final BleConnectionManager mgr; + + ConnectingState(BleConnectionManager mgr) { + this.mgr = mgr; + } + + @Override + public String getName() { return "ConnectingState"; } + + @Override + public void onEnter() { + mgr.sendMessage(MSG_INIT); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_INIT: + try { + if (Build.VERSION.SDK_INT < 28) { + mgr.bleConnHelper.discoverServices(); + } else { + Log.d(TAG, "onServiceChanged() Connection Model enabled," + + " transitioning to Connected State."); + mgr.transitionTo(mgr.connectedState); + } + } catch (BleException e) { + mgr.handleBleException(e); + mgr.transitionTo(mgr.disconnectingState); + } + return true; + + case MSG_BT_ADAPTER_STATE_CHANGED: + if (msg.arg1 == BluetoothAdapter.STATE_OFF) { + mgr.transitionTo(mgr.disconnectingState); + } + return true; + + case MSG_CONNECTION_CONFIG_UPDATE: { + ConnectionConfiguration cfg = mgr.config.get(); + if (cfg == null || !cfg.enabled) { + mgr.transitionTo(mgr.disconnectingState); + } + return true; + } + + case MSG_SERVICE_DISCOVERY_COMPLETE: + if (Build.VERSION.SDK_INT >= 28) { + Log.e(TAG, "Unexpected Services Discovered in Connecting" + + " w/ OnServiceChangedModel. Disconnecting."); + mgr.transitionTo(mgr.disconnectingState); + } else { + try { + mgr.gattHelper.updateCurrentTime(); + } catch (BleException e) { + Log.d(TAG, "ConnectingState: failed to update current time"); + mgr.handleBleException(e); + } + Log.d(TAG, "ConnectingState: service discovery complete," + + " transitioning to ConnectedState"); + mgr.transitionTo(mgr.connectedState); + } + return true; + + case MSG_GATT_CONNECTION_CLOSED: + mgr.transitionTo(mgr.disconnectingState); + return true; + + case MSG_ON_SERVICE_CHANGED: + if (Build.VERSION.SDK_INT >= 28) { + mgr.transitionTo(mgr.connectedState); + } + return true; + + default: + return mgr.handleUnhandledMessage(msg); + } + } + } + + static final class ConnectedState extends BleState { + private final BleConnectionManager mgr; + + ConnectedState(BleConnectionManager mgr) { + this.mgr = mgr; + } + + @Override + public String getName() { + return "ConnectedState"; + } + + @Override + public void onEnter() { + mgr.scanAttemptCount.set(0); + mgr.sendMessage(MSG_INIT); + } + + @Override + public void onExit() {} + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_INIT: + mgr.sendMessage(MSG_READY_TO_SETUP_ANCS); + return true; + + case MSG_UPDATE_TIME: + try { + mgr.gattHelper.updateCurrentTime(); + } catch (BleException e) { + Log.w(TAG, "ConnectedState: failed to update time"); + mgr.handleBleException(e); + mgr.transitionTo(mgr.disconnectingState); + } + return true; + + case MSG_ON_SERVICE_CHANGED: + Log.d(TAG, "ConnectedState: onServiceChanged received"); + try { + mgr.gattHelper.updateCurrentTime(); + } catch (BleException e) { + Log.w(TAG, "ConnectedState: exception after service change"); + mgr.handleBleException(e); + if (Build.VERSION.SDK_INT < 28) { + mgr.transitionTo(mgr.disconnectingState); + } + } + return true; + + case MSG_GATT_CONNECTION_CLOSED: + Log.d(TAG, "ConnectedState: GATT connection closed, disconnecting"); + mgr.transitionTo(mgr.disconnectingState); + return true; + + case MSG_RESET_CONNECTION: + Log.d(TAG, "ConnectedState: reset connection requested"); + mgr.transitionTo(mgr.disconnectingState); + return true; + + case MSG_DECOMMISSION_WATCH: + Log.d(TAG, "ConnectedState: decommissioning watch"); + mgr.transitionTo(mgr.errorDisconnectedState); + return true; + + case MSG_BT_ADAPTER_STATE_CHANGED: + if (msg.arg1 == BluetoothAdapter.STATE_OFF) { + mgr.transitionTo(mgr.disconnectingState); + } + return true; + + case MSG_CONNECTION_CONFIG_UPDATE: { + ConnectionConfiguration cfg = mgr.config.get(); + if (cfg == null || !cfg.enabled) { + mgr.transitionTo(mgr.disconnectingState); + } + return true; + } + + case MSG_RECONNECT_CHARACTERISTIC_CHANGED: + case MSG_RESET_CHARACTERICTIC_CHANGED: + mgr.transitionTo(mgr.disconnectingState); + return true; + + case MSG_READY_TO_SETUP_ANCS: + return true; + + default: + return mgr.handleUnhandledMessage(msg); + } + } + } + + static final class DisconnectingState extends BleState { + private final BleConnectionManager mgr; + + DisconnectingState(BleConnectionManager mgr) { + this.mgr = mgr; + } + + @Override + public String getName() { + return "DisconnectingState"; + } + + @Override + public void onEnter() { + mgr.sendMessage(MSG_INIT); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_INIT: + try { + if (mgr.bleConnHelper.isConnected()) { + Log.d(TAG, "DisconnectingState: disconnecting GATT"); + mgr.bleConnHelper.disconnect(); + } else { + Log.d(TAG, "DisconnectingState: already disconnected"); + } + } catch (BleException e) { + Log.w(TAG, "DisconnectingState: exception while disconnecting"); + } finally { + mgr.transitionTo(mgr.discoveredState); + } + return true; + + case MSG_GATT_CONNECTION_CLOSED: + mgr.transitionTo(mgr.discoveredState); + return true; + + default: + return mgr.handleUnhandledMessage(msg); + } + } + } + + static final class ServiceOffState extends BleState { + private final BleConnectionManager mgr; + + ServiceOffState(BleConnectionManager mgr) { + this.mgr = mgr; + } + + @Override + public String getName() { + return "ServiceOffState"; + } + + @Override + public void onEnter() { + if (mgr.isReceiverRegistered.compareAndSet(true, false)) { + mgr.context.unregisterReceiver(mgr.btStateReceiver); + } + ConnectionConfiguration cfg = mgr.config.get(); + if (cfg != null && cfg.enabled) { + mgr.sendMessage(MSG_CONNECTION_CONFIG_UPDATE); + } + } + + @Override + public boolean handleMessage(Message msg) { + if (msg.what == MSG_CONNECTION_CONFIG_UPDATE) { + ConnectionConfiguration cfg = mgr.config.get(); + if (cfg != null && cfg.enabled) { + mgr.transitionTo(mgr.disconnectedState); + } + } + return true; + } + } + +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleConnectionManagerInterface.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleConnectionManagerInterface.java new file mode 100644 index 0000000000..9500e6b989 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleConnectionManagerInterface.java @@ -0,0 +1,9 @@ +package org.microg.gms.wearable.bluetooth; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +public interface BleConnectionManagerInterface { + void updateConfiguration(ConnectionConfiguration config); + void quit(); + void quitSafely(); +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleDeviceDiscoverer.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleDeviceDiscoverer.java new file mode 100644 index 0000000000..63c7186f51 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleDeviceDiscoverer.java @@ -0,0 +1,260 @@ +package org.microg.gms.wearable.bluetooth; + +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.RequiresPermission; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class BleDeviceDiscoverer { + private static final String TAG = "BleDeviceDiscoverer"; + + private final Context context; + private final BluetoothAdapter bluetoothAdapter; + private final Map deviceFilters = new HashMap<>(); + private final Map deviceCallbacks = new HashMap<>(); + private final Object lock = new Object(); + + private BluetoothLeScanner scanner; + private boolean isScanning = false; + private ScanCallback scanCallback; + + public interface DeviceDiscoveryCallback { + void onDeviceDiscovered(BluetoothDevice device); + } + + public BleDeviceDiscoverer(Context context, BluetoothAdapter bluetoothAdapter) { + this.context = context; + this.bluetoothAdapter = bluetoothAdapter; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (bluetoothAdapter != null) { + this.scanner = bluetoothAdapter.getBluetoothLeScanner(); + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + public void addDevice(BluetoothDevice device, DeviceDiscoveryCallback callback) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + Log.w(TAG, "BLE scanning not supported on Android < 5.0"); + return; + } + + synchronized (lock) { + if (deviceFilters.containsKey(device)) { + Log.d(TAG, "Device already being watched: " + device.getAddress()); + return; + } + + Log.d(TAG, "Adding device to watch: " + device.getAddress()); + + ScanFilter filter = new ScanFilter.Builder() + .setDeviceAddress(device.getAddress()) + .build(); + + deviceFilters.put(device, filter); + deviceCallbacks.put(device, callback); + + updateScanning(); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + public void removeDevice(BluetoothDevice device) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + + synchronized (lock) { + if (!deviceFilters.containsKey(device)) { + Log.d(TAG, "Device not being watched: " + device.getAddress()); + return; + } + + Log.d(TAG, "Removing device from watch: " + device.getAddress()); + + deviceFilters.remove(device); + deviceCallbacks.remove(device); + + updateScanning(); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + public void clear() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + + synchronized (lock) { + Log.d(TAG, "Clearing all devices"); + deviceFilters.clear(); + deviceCallbacks.clear(); + stopScanning(); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + private void updateScanning() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + + if (deviceFilters.isEmpty()) { + stopScanning(); + return; + } + + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) { + Log.w(TAG, "Bluetooth not available, cannot start scanning"); + return; + } + + if (scanner == null) { + scanner = bluetoothAdapter.getBluetoothLeScanner(); + if (scanner == null) { + Log.w(TAG, "BluetoothLeScanner not available"); + return; + } + } + + if (isScanning) { + stopScanning(); + } + + startScanning(); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + private void startScanning() { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + + try { + scanCallback = new ScanCallback() { + @Override + public void onScanResult(int callbackType, ScanResult result) { + handleScanResult(result); + } + + @Override + public void onBatchScanResults(List results) { + for (ScanResult result : results) { + handleScanResult(result); + } + } + + @Override + public void onScanFailed(int errorCode) { + Log.e(TAG, "BLE scan failed with error: " + errorCode); + synchronized (lock) { + isScanning = false; + } + } + }; + + List filters = new ArrayList<>(deviceFilters.values()); + + ScanSettings settings = new ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) + .build(); + + scanner.startScan(filters, settings, scanCallback); + isScanning = true; + + Log.d(TAG, String.format("Started BLE scanning for %d devices", filters.size())); + + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for BLE scanning", e); + } catch (Exception e) { + Log.e(TAG, "Error starting BLE scan", e); + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + private void stopScanning() { + if (!isScanning) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + if (scanner != null && scanCallback != null) { + scanner.stopScan(scanCallback); + Log.d(TAG, "Stopped BLE scanning"); + } + } catch (Exception e) { + Log.w(TAG, "Error stopping BLE scan", e); + } finally { + isScanning = false; + scanCallback = null; + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + private void handleScanResult(ScanResult result) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + + if (result == null || result.getDevice() == null) { + return; + } + + BluetoothDevice device = result.getDevice(); + + synchronized (lock) { + DeviceDiscoveryCallback callback = deviceCallbacks.get(device); + if (callback != null) { + Log.d(TAG, String.format("Device discovered: %s (RSSI: %d)", + device.getAddress(), result.getRssi())); + + try { + callback.onDeviceDiscovered(device); + } catch (Exception e) { + Log.e(TAG, "Error in discovery callback", e); + } + + deviceFilters.remove(device); + deviceCallbacks.remove(device); + + if (deviceFilters.isEmpty()) { + stopScanning(); + } + } + } + } + + public boolean isScanning() { + synchronized (lock) { + return isScanning; + } + } + + public int getWatchedDeviceCount() { + synchronized (lock) { + return deviceFilters.size(); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + public void shutdown() { + clear(); + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleException.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleException.java new file mode 100644 index 0000000000..9c3d6c734b --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleException.java @@ -0,0 +1,32 @@ +package org.microg.gms.wearable.bluetooth; + +public class BleException extends Exception { + public static final int CODE_UNKNOWN = -1; + public static final int CODE_GATT_INVALID_HANDLE = 1; + public static final int CODE_GATT_READ_NOT_PERMITTED = 2; + public static final int CODE_GATT_WRITE_NOT_PERMITTED = 3; + + public static final int CODE_TIME_SERVICE_NOT_FOUND = 256; + public static final int CODE_MISSING_CLOCKWORK_CHARS = 258; + public static final int CODE_INVALID_DECOMMISSION = 259; + public static final int CODE_SERVICE_NOT_FOUND = 260; + public static final int CODE_TIME_CHAR_INVALID = 261; + public static final int CODE_TIMEZONE_OFFSET_INVALID = 262; + + public final int statusCode; + + public BleException(String message) { + super(message); + this.statusCode = CODE_UNKNOWN; + } + + public BleException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public BleException(Throwable cause) { + super(cause); + this.statusCode = CODE_UNKNOWN; + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleManager.java new file mode 100644 index 0000000000..8c903dcbda --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleManager.java @@ -0,0 +1,120 @@ +package org.microg.gms.wearable.bluetooth; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.content.Context; +import android.os.HandlerThread; +import android.util.Log; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.WearableImpl; +import org.microg.gms.wearable.proto.Connect; + +import java.io.Closeable; +import java.util.HashMap; +import java.util.Map; + +public class BleManager implements Closeable { + private static final String TAG = "BleManager"; + + private final Context context; + private final WearableImpl wearable; + private final BluetoothAdapter btAdapter; + private final BluetoothManager btManager; + + private final Map entries = new HashMap<>(); + + public BleManager(Context context, WearableImpl wearable) { + this.context = context; + this.wearable = wearable; + btManager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); + btAdapter = btManager != null ? btManager.getAdapter() : BluetoothAdapter.getDefaultAdapter(); + } + + + + public synchronized void enable(ConnectionConfiguration config) { + if (config.address == null) { + Log.w(TAG, "enable: address is null, ignoring"); + return; + } + + Entry e = entries.get(config.address); + if (e != null) { + Log.d(TAG, "enable: updating existing manager for " + config.address); + e.mgr.updateConfiguration(config); + return; + } + + Log.d(TAG, "enable: creating BleConnectionManager for " + config.address); + e = createEntry(config); + entries.put(config.address, e); + } + + public synchronized void disable(ConnectionConfiguration config) { + if (config.address == null) return; + + Entry e = entries.remove(config.address); + if (e != null) { + Log.d(TAG, "disable: stopping manager for " + config.address); + stopEntry(e); + } + } + + @Override + public synchronized void close() { + Log.d(TAG, "close: shutting down all BLE managers"); + for (Entry e : entries.values()) { + stopEntry(e); + } + entries.clear(); + } + + private Entry createEntry(ConnectionConfiguration config) { + HandlerThread thread = new HandlerThread( + "BleConnMgr-" + config.address); + thread.start(); + + BluetoothGattHelperImpl gattHelper = + new BluetoothGattHelperImpl(context, btManager); + + BleScannerImpl scanner = new BleScannerImpl(btAdapter); + BleServicesHandlerImpl svc = new BleServicesHandlerImpl(gattHelper); + + BleConnectionManager mgr = new BleConnectionManager( + context, + btAdapter, + scanner, + gattHelper, + svc, + thread.getLooper(), + config); + + return new Entry(mgr, thread); + } + + private static void stopEntry(Entry e) { + try { + e.mgr.quit(); + } catch (Exception ex) { + Log.w(TAG, "quit error: " + ex); + } + try { + e.thread.quitSafely(); + } catch (Exception ex) { + Log.w(TAG, "thread quit error: " + ex); + } + } + + private static final class Entry { + final BleConnectionManager mgr; + final HandlerThread thread; + + Entry(BleConnectionManager mgr, HandlerThread thread) { + this.mgr = mgr; + this.thread = thread; + } + } + +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleScanner.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleScanner.java new file mode 100644 index 0000000000..61e33ce0c3 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleScanner.java @@ -0,0 +1,12 @@ +package org.microg.gms.wearable.bluetooth; + +public interface BleScanner { + boolean isScanning(); + void stopScan(); + void startScan(String address, ScanListener listener); + + interface ScanListener { + void onDeviceFound(String address); + void onScanFailed(int errorCode); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleScannerImpl.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleScannerImpl.java new file mode 100644 index 0000000000..ff3d8ffe6c --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleScannerImpl.java @@ -0,0 +1,140 @@ +package org.microg.gms.wearable.bluetooth; + +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.RequiresPermission; + +import java.util.Collections; +import java.util.List; + +class BleScannerImpl implements BleScanner { + private static final String TAG = "BleScannerImpl"; + + private final BluetoothAdapter btAdapter; + private volatile ScanCallback activeCb; + private volatile boolean scanning; + + // For API 19 + private BluetoothAdapter.LeScanCallback leScanCallback; + private volatile ScanListener currentListener; + private volatile String targetAddress; + + BleScannerImpl(BluetoothAdapter btAdapter) { + this.btAdapter = btAdapter; + } + + @Override + public boolean isScanning() { + return scanning; + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + public void stopScan() { + if (!scanning) return; + BluetoothLeScanner scanner = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + scanner = btAdapter.getBluetoothLeScanner(); + if (scanner != null && activeCb != null) { + try { + scanner.stopScan(activeCb); + } catch (Exception e) { + Log.w(TAG, "stopScan failed: " + e); + } + } + activeCb = null; + scanning = false; + } else { + if (leScanCallback != null) { + btAdapter.stopLeScan(leScanCallback); + leScanCallback = null; + } + currentListener = null; + targetAddress = null; + } + Log.d(TAG, "scan stopped"); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + public void startScan(String address, ScanListener listener) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + startScanOld(address, listener); + } else { + BluetoothLeScanner scanner = btAdapter.getBluetoothLeScanner(); + if (scanner == null || !btAdapter.isEnabled()) { + Log.w(TAG, "BLE scanner unavailable"); + listener.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR); + return; + } + + List filters = Collections.singletonList( + new ScanFilter.Builder().setDeviceAddress(address).build()); + + ScanSettings settings = new ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build(); + + activeCb = new ScanCallback() { + @Override + public void onScanResult(int callbackType, ScanResult result) { + String found = result.getDevice().getAddress(); + Log.d(TAG, "device found: " + found); + listener.onDeviceFound(found); + } + + @Override + public void onScanFailed(int errorCode) { + Log.e(TAG, "scan failed: " + errorCode); + scanning = false; + listener.onScanFailed(errorCode); + } + }; + + try { + scanner.startScan(filters, settings, activeCb); + scanning = true; + Log.d(TAG, "scan started for " + address); + } catch (Exception e) { + Log.e(TAG, "startScan threw: " + e); + activeCb = null; + listener.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR); + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + private void startScanOld(final String address, final ScanListener listener) { + if (scanning) stopScan(); + targetAddress = address; + currentListener = listener; + + leScanCallback = (device, rssi, scanRecord) -> { + if (device == null) return; + final String found = device.getAddress(); + if (targetAddress == null || targetAddress.equalsIgnoreCase(found)) { + Log.d(TAG, "startScanOld: " + found); + ScanListener l = currentListener; + if (l != null) l.onDeviceFound(found); + } + }; + + boolean started = btAdapter.startLeScan(leScanCallback); + if (started) { + scanning = true; + } else { + leScanCallback = null; + currentListener = null; + Log.e(TAG, "startScanOld: returned false"); + listener.onScanFailed(2); + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleServicesHandler.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleServicesHandler.java new file mode 100644 index 0000000000..02ca443312 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleServicesHandler.java @@ -0,0 +1,6 @@ +package org.microg.gms.wearable.bluetooth; + +public interface BleServicesHandler { + void cleanup(); + void updateCurrentTime() throws BleException; +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleServicesHandlerImpl.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleServicesHandlerImpl.java new file mode 100644 index 0000000000..9f7b0e9138 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleServicesHandlerImpl.java @@ -0,0 +1,89 @@ +package org.microg.gms.wearable.bluetooth; + +import android.Manifest; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.util.Log; + +import androidx.annotation.RequiresPermission; + +import java.util.Calendar; +import java.util.TimeZone; + +public class BleServicesHandlerImpl implements BleServicesHandler { + private static final String TAG = "BleServicesHandlerImpl"; + + private final BluetoothGattHelperImpl gattHelper; + + BleServicesHandlerImpl(BluetoothGattHelperImpl gattHelper) { + this.gattHelper = gattHelper; + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void updateCurrentTime() throws BleException { + BluetoothGatt g = gattHelper.getGatt(); + if (g == null) throw new BleException("GATT not connected"); + + BluetoothGattService cts = g.getService(BluetoothGattHelperImpl.UUID_CTS); + if (cts == null) { + throw new BleException("Current Time Service not found", + BleException.CODE_TIME_SERVICE_NOT_FOUND); + } + + BluetoothGattCharacteristic ctChar = + cts.getCharacteristic(BluetoothGattHelperImpl.UUID_CURRENT_TIME); + if (ctChar == null) { + throw new BleException("Current Time characteristic not found", + BleException.CODE_TIME_CHAR_INVALID); + } + + Calendar now = Calendar.getInstance(TimeZone.getDefault()); + byte[] value = new byte[10]; + int year = now.get(Calendar.YEAR); + value[0] = (byte) (year & 0xFF); + value[1] = (byte) ((year >> 8) & 0xFF); + value[2] = (byte) (now.get(Calendar.MONTH) + 1); + value[3] = (byte) now.get(Calendar.DAY_OF_MONTH); + value[4] = (byte) now.get(Calendar.HOUR_OF_DAY); + value[5] = (byte) now.get(Calendar.MINUTE); + value[6] = (byte) now.get(Calendar.SECOND); + int dow = now.get(Calendar.DAY_OF_WEEK); + value[7] = (byte) (dow == Calendar.SUNDAY ? 7 : dow - 1); + value[8] = 0; + value[9] = 0x02; + + ctChar.setValue(value); + ctChar.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); + + if (!g.writeCharacteristic(ctChar)) { + throw new BleException("writeCharacteristic(currentTime) failed"); + } + Log.d(TAG, "updateCurrentTime written"); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void cleanup() { + BluetoothGatt g = gattHelper.getGatt(); + if (g == null) return; + try { + BluetoothGattService cts = g.getService(BluetoothGattHelperImpl.UUID_CTS); + if (cts == null) return; + BluetoothGattCharacteristic c = + cts.getCharacteristic(BluetoothGattHelperImpl.UUID_CURRENT_TIME); + if (c == null) return; + g.setCharacteristicNotification(c, false); + BluetoothGattDescriptor cccd = c.getDescriptor(BluetoothGattHelperImpl.UUID_CCCD); + if (cccd != null) { + cccd.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE); + g.writeDescriptor(cccd); + } + Log.d(TAG, "cleanup: notifications disabled"); + } catch (Exception e) { + Log.w(TAG, "cleanup error: " + e); + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleState.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleState.java new file mode 100644 index 0000000000..9a18b9946f --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleState.java @@ -0,0 +1,13 @@ +package org.microg.gms.wearable.bluetooth; + +import android.os.Message; + +public abstract class BleState { + public abstract String getName(); + + public void onEnter() {} + + public void onExit() {} + + public abstract boolean handleMessage(Message msg); +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleStateMachine.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleStateMachine.java new file mode 100644 index 0000000000..115d16b3e9 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleStateMachine.java @@ -0,0 +1,93 @@ +package org.microg.gms.wearable.bluetooth; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Log; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public abstract class BleStateMachine extends Handler { + private final String name; + private final Map> transitions = new HashMap<>(); + private BleState currentState; + private BleState errorState; + + protected BleStateMachine(String name, Looper looper) { + super(looper); + this.name = name; + } + + protected void addState(BleState state) { + transitions.put(state, new ArrayList<>()); + } + + protected void addTransition(BleState from, BleState to) { + List targets = transitions.get(from); + + if (targets == null) { + Log.w(name, "addTransition: unknown source state " + from.getName()); + return; + } + + if (!targets.contains(to)) { + targets.add(to); + } + } + + protected void setErrorState(BleState state){ + this.errorState = state; + } + + protected void start() { + + } + + public void transitionTo(BleState next) { + if (currentState != null) { + currentState.onExit(); + } + currentState = next; + if (currentState != null) { + currentState.onEnter(); + } + } + + public BleState currentState() { + return currentState; + } + + public void sendMessage(int what) { + sendMessage(obtainMessage(what)); + } + + public void sendMessageDelayed(int what, long delay) { + sendMessageDelayed(obtainMessage(what), delay); + } + + public void sendBtAdapterStateMsg(int state) { + Message msg = obtainMessage(BleConnectionManager.MSG_BT_ADAPTER_STATE_CHANGED); + msg.arg1 = state; + sendMessage(msg); + } + + @Override + public void handleMessage(Message msg) { + if (currentState != null && !currentState().handleMessage(msg)) { + Log.w(name, "Unhandled message " + msg.what + " in state " + currentState.getName()); + } + } + + protected String getMessageName(int what) { + return String.valueOf(what); + } + + protected boolean shouldLogMessage(Message msg) { + return true; + } + + protected void onQuiting() {} +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleTimeoutException.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleTimeoutException.java new file mode 100644 index 0000000000..69793f077c --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleTimeoutException.java @@ -0,0 +1,7 @@ +package org.microg.gms.wearable.bluetooth; + +public class BleTimeoutException extends BleException { + public BleTimeoutException(String message) { + super(message); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothClient.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothClient.java new file mode 100644 index 0000000000..99f0c3beed --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothClient.java @@ -0,0 +1,289 @@ +package org.microg.gms.wearable.bluetooth; + +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.util.Log; + +import androidx.annotation.RequiresPermission; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.WearableImpl; + +import java.io.Closeable; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +public class BluetoothClient implements Closeable { + private static final String TAG = "GmsWearBtClient"; + + private final Context context; + private final BluetoothAdapter btAdapter; + private final BroadcastReceiver btStateReceiver; + private final BroadcastReceiver aclConnReceiver; + + private final ConcurrentHashMap configurations = new ConcurrentHashMap<>(); + private final ConcurrentHashMap connections = new ConcurrentHashMap<>(); + + private final WearableImpl wearableImpl; + + private final ScheduledExecutorService executor; + private final BleDeviceDiscoverer bleDiscoverer; + + private volatile boolean isShutdown = false; + + public BluetoothClient(Context context, WearableImpl wearableImpl) { + this.context = context; + this.btAdapter = BluetoothAdapter.getDefaultAdapter(); + + this.wearableImpl = wearableImpl; + + this.btStateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(intent.getAction())) { + int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, + BluetoothAdapter.ERROR); + onBluetoothStateChanged(state); + } + } + }; + + this.aclConnReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BluetoothDevice.ACTION_ACL_CONNECTED.equals(intent.getAction())) { + BluetoothDevice device = intent.getParcelableExtra( + BluetoothDevice.EXTRA_DEVICE); + if (device != null) { + onAclConnected(device); + } + } + } + }; + + this.bleDiscoverer = new BleDeviceDiscoverer(context, btAdapter); + this.executor = Executors.newScheduledThreadPool(2); + + context.registerReceiver(btStateReceiver, + new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)); + context.registerReceiver(aclConnReceiver, + new IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED)); + } + + public void addConfig(ConnectionConfiguration config) { + if (isShutdown) { + Log.w(TAG, "Client is shutdown, ignoring addConfig"); + return; + } + + validateConfig(config); + + String address = config.address; + + synchronized (this) { + if (configurations.containsKey(address)) { + Log.d(TAG, "Configuration already exists for " + address + ", updating"); + + configurations.put(address, config); + + BluetoothConnectionThread thread = connections.get(address); + if (thread != null) { + if (thread.isConnectionHealthy()) { + Log.d(TAG, "Connection is healthy, ignoring retry"); + } else { + Log.d(TAG, "Connection unhealthy, triggering retry"); + thread.resetBackoffAndRetryConnection(); + } + } else { + startConnection(config); + } + + return; + } + + configurations.put(address, config); + + if (btAdapter != null && btAdapter.isEnabled()) { + startConnection(config); + } else { + Log.w(TAG, "Bluetooth disabled, deferring connection"); + } + } + } + + public void removeConfig(ConnectionConfiguration config) { + if (isShutdown) { + return; + } + + validateConfig(config); + + String address = config.address; + Log.d(TAG, "Removing configuration for " + address); + + synchronized (this) { + BluetoothConnectionThread thread = connections.get(address); + if (thread != null) { + thread.close(); + connections.remove(address); + } + + configurations.remove(address); + } + } + + private void startConnection(ConnectionConfiguration config) { + if (isShutdown) { + return; + } + + String address = config.address; + + synchronized (this) { + if (connections.containsKey(address)) { + Log.d(TAG, "Connection already active for " + address); + return; + } + + if (btAdapter == null || !btAdapter.isEnabled()) { + Log.w(TAG, "Bluetooth not available, deferring connection"); + return; + } + + Log.d(TAG, "Starting connection for " + address); + + BluetoothConnectionThread thread = new BluetoothConnectionThread( + context, config, btAdapter, wearableImpl, executor, bleDiscoverer + ); + + connections.put(address, thread); + thread.start(); + } + } + + private void onAclConnected(BluetoothDevice device) { + String address = device.getAddress(); + + synchronized (this) { + ConnectionConfiguration config = configurations.get(address); + if (config == null) { + return; + } + + BluetoothConnectionThread thread = connections.get(address); + + if (thread == null) { + Log.d(TAG, "ACL_CONNECTED for configured device " + address + + ", starting connection"); + startConnection(config); + } else if (!thread.isConnectionHealthy()) { + Log.d(TAG, "ACL_CONNECTED for unhealthy connection " + address + + ", requesting retry"); + thread.retryConnection(); + } else { + Log.d(TAG, "ACL_CONNECTED for healthy connection " + address + + ", ignoring"); + } + } + } + + private void onBluetoothStateChanged(int state) { + Log.d(TAG, "Bluetooth state changed to " + state); + + synchronized (this) { + if (state == BluetoothAdapter.STATE_ON) { + for (ConnectionConfiguration config : configurations.values()) { + String address = config.address; + if (!connections.containsKey(address)) { + startConnection(config); + } else { + BluetoothConnectionThread thread = connections.get(address); + if (thread != null) { + thread.resetBackoffAndRetryConnection(); + } + } + } + } else if (state == BluetoothAdapter.STATE_OFF) { + if (btAdapter != null && btAdapter.isEnabled()) { + Log.d(TAG, "Ignoring STATE_OFF - adapter still enabled"); + return; + } + + for (BluetoothConnectionThread thread : connections.values()) { + thread.close(); + } + connections.clear(); + } + } + } + + private static void validateConfig(ConnectionConfiguration config){ + if (config == null || config.address == null) + throw new IllegalArgumentException("Invalid configuration: config or address is null"); + + int type = config.type; + if ( type != WearableImpl.TYPE_BLUETOOTH_RFCOMM && type != 5) + throw new IllegalArgumentException("Invalid connection type: " + type); + + if (config.role != WearableImpl.ROLE_CLIENT) + throw new IllegalArgumentException("Role is not client: " + config.role); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + @Override + public void close() { + if (isShutdown) { + return; + } + + Log.d(TAG, "Shutting down BluetoothClient"); + isShutdown = true; + + synchronized (this) { + for (BluetoothConnectionThread thread : connections.values()) { + thread.close(); + } + + for (BluetoothConnectionThread thread : connections.values()) { + try { + thread.join(5000); + if (thread.isAlive()) { + Log.w(TAG, "Thread did not stop in time: " + thread.getName()); + } + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while waiting for thread", e); + } + } + + connections.clear(); + configurations.clear(); + } + + bleDiscoverer.shutdown(); + + executor.shutdownNow(); + + try { + context.unregisterReceiver(btStateReceiver); + } catch (Exception e) { + Log.w(TAG, "Error unregistering btStateReceiver", e); + } + + try { + context.unregisterReceiver(aclConnReceiver); + } catch (Exception e) { + Log.w(TAG, "Error unregistering aclConnectedReceiver", e); + } + + Log.d(TAG, "BluetoothClient closed"); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothConnectionThread.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothConnectionThread.java new file mode 100644 index 0000000000..af1fd04468 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothConnectionThread.java @@ -0,0 +1,501 @@ +package org.microg.gms.wearable.bluetooth; + +import android.Manifest; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothSocket; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.Uri; +import android.os.Build; +import android.os.PowerManager; +import android.os.SystemClock; +import android.provider.Settings; +import android.util.Log; + +import androidx.annotation.RequiresPermission; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.ConnectHandshake; +import org.microg.gms.wearable.MessageHandler; +import org.microg.gms.wearable.TransportConnectionHandler; +import org.microg.gms.wearable.WearableConnection; +import org.microg.gms.wearable.WearableImpl; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.Closeable; +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public class BluetoothConnectionThread extends Thread implements Closeable { + private static final String TAG = "GmsWearBtConnThread"; + + private static final UUID WEAR_BT_UUID = UUID.fromString("5e8945b0-9525-11e3-a5e2-0800200c9a66"); + + private static final long MIN_ATTEMPT_INTERVAL_MS = 3000; + private static final long SOCKET_CONNECT_TIMEOUT_MS = 30000; + private static final long ACTIVITY_TIMEOUT_MS = 5000; + + private final Context context; + private final ConnectionConfiguration config; + private final BluetoothAdapter btAdapter; + private final BluetoothDevice btDevice; + private final WearableImpl wearableImpl; + private final ScheduledExecutorService executor; + + private final WakeLockManager wakeLockManager; + private final RetryStrategy retryStrategy; + private final AlarmManagerHelper alarmHelper; + private final BleDeviceDiscoverer bleDiscoverer; // Nullable + + private final Lock lock = new ReentrantLock(); + private final Condition retryCondition = lock.newCondition(); + private final AtomicBoolean running = new AtomicBoolean(true); + private final AtomicBoolean immediateRetry = new AtomicBoolean(false); + + private volatile boolean isConnected = false; + private volatile long lastActivityTime = 0; + private long lastAttemptTime = 0; + + private BluetoothSocket socket; + private WearableConnection wearableConnection; + + private BroadcastReceiver retryReceiver; + private boolean receiverRegistered = false; + + public BluetoothConnectionThread(Context context, ConnectionConfiguration config, + BluetoothAdapter btAdapter, WearableImpl wearableImpl, + ScheduledExecutorService executor, BleDeviceDiscoverer bleDiscoverer) { + super("BtThread-" + config.address); + this.context = context; + this.config = config; + this.btAdapter = btAdapter; + this.wearableImpl = wearableImpl; + + this.executor = executor; + this.bleDiscoverer = bleDiscoverer; + + this.btDevice = btAdapter.getRemoteDevice(config.address); + + this.wakeLockManager = new WakeLockManager(context, + "BtConnect:" + config.address, executor); + this.retryStrategy = RetryStrategy.fromPolicy(config.connectionRetryStrategy); + this.alarmHelper = new AlarmManagerHelper(context); + + registerRetryReceiver(); + + } + + private void registerRetryReceiver() { + retryReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null && "com.google.android.gms.wearable.RETRY_CONNECTION".equals(intent.getAction())) { + String address = intent.getData() != null ? intent.getData().getAuthority() : null; + if (config.address.equals(address)) { + Log.d(TAG, "Alarm triggered retry for " + config.address); + signalRetry(); + } + } + } + }; + + IntentFilter filter = new IntentFilter("com.google.android.gms.wearable.RETRY_CONNECTION"); + filter.addDataScheme("wearable"); + + context.registerReceiver(retryReceiver, filter); + receiverRegistered = true; + } + + public boolean isConnectionHealthy(){ + if (!isConnected || wearableConnection == null) { + return false; + } + + long timeSinceActivity = System.currentTimeMillis() - lastActivityTime; + return isAlive() && !isInterrupted() && timeSinceActivity < ACTIVITY_TIMEOUT_MS; + } + + private void markActivity() { + lastActivityTime = System.currentTimeMillis(); + } + + @RequiresPermission(allOf = {Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT}) + @Override + public void run(){ + Log.d(TAG, "Bluetooth connection thread started for " + config.address); + + while (running.get() && !isInterrupted()) { + try { + enforceMinInterval(); + + if (!running.get()) break; + + wakeLockManager.acquire("connect", SOCKET_CONNECT_TIMEOUT_MS + 5000); + + try { + connect(); + retryStrategy.reset(); + + } catch (IOException e) { + Log.w(TAG, "Connection failed: " + e.getMessage()); + } catch (InterruptedException e) { + Log.d(TAG, "Connection interrupted"); + if (!running.get()) break; + } catch (Exception e) { + Log.e(TAG, "Unexpected error", e); + } finally { + closeSocket(); + wakeLockManager.release("connect"); + } + + if (running.get() && !isInterrupted()) { + waitForRetry(); + } + + } catch (InterruptedException e) { + Log.d(TAG, "Thread interrupted"); + if (!running.get()) break; + } catch (Exception e) { + Log.e(TAG, "Unexpected error in main loop", e); + } + } + + Log.d(TAG, "Bluetooth connection thread stopped for " + config.address); + cleanup(); + } + + private void enforceMinInterval() { + long now = System.currentTimeMillis(); + long elapsed = now - lastAttemptTime; + + if (elapsed < MIN_ATTEMPT_INTERVAL_MS && lastAttemptTime > 0) { + long sleepTime = MIN_ATTEMPT_INTERVAL_MS - elapsed; + Log.d(TAG, "Enforcing min interval, sleeping " + sleepTime + "ms"); + try { + Thread.sleep(sleepTime); + } catch (InterruptedException e) { + if (!running.get()) return; + } + } + + lastAttemptTime = System.currentTimeMillis(); + } + + @RequiresPermission(allOf = {Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN}) + private void connect() throws IOException, InterruptedException { + if (!running.get() || btAdapter == null || !btAdapter.isEnabled()) { + throw new IOException("Bluetooth not available"); + } + + Log.d(TAG, "Connecting to " + config.address); + + socket = btDevice.createRfcommSocketToServiceRecord(WEAR_BT_UUID); + + if (btAdapter.isDiscovering()) { + btAdapter.cancelDiscovery(); + } + + connectSocketWithTimeout(socket); + + Log.d(TAG, "Socket connected to " + config.address); + + isConnected = true; + markActivity(); + + ConnectHandshake.LocalIdentity local = new ConnectHandshake.LocalIdentity( + wearableImpl.getLocalNodeId(), + Build.MODEL, + getAndroidId(), + wearableImpl.getClockworkNodePreferences().getNetworkId(), + null, + config.migrating, + config.migrating ? wearableImpl.getClockworkNodePreferences().getPeerNodeId() : null + ); + + BluetoothWearableConnection btConn = + new BluetoothWearableConnection(socket, local, WearableConnection.NOOP); + + wearableConnection = btConn; + + if (!btConn.handshake()) { + throw new IOException("Bluetooth handshake failed"); + } + + new TransportConnectionHandler(wearableImpl, config).handle(btConn); + } + + private long getAndroidId() { + try { + String s = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + return s != null ? Long.parseUnsignedLong(s, 16) : 0L; + } catch (NumberFormatException e) { + return 0L; + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void connectSocketWithTimeout(BluetoothSocket socket) throws IOException, InterruptedException { + final AtomicBoolean connected = new AtomicBoolean(false); + final AtomicBoolean timedOut = new AtomicBoolean(false); + final Object connectLock = new Object(); + final IOException[] exception = new IOException[1]; + + Thread connectThread = new Thread(() -> { + try { + synchronized (connectLock) { + if (timedOut.get()) return; + } + + socket.connect(); + + synchronized (connectLock) { + if (!timedOut.get()) { + connected.set(true); + } else { + try { + socket.close(); + } catch (IOException ignored) {} + } + } + } catch (IOException e) { + synchronized (connectLock) { + if (!timedOut.get()) { + exception[0] = e; + } + } + } + }, "BtSocketConnect-" + config.address); + + connectThread.start(); + + long startTime = System.currentTimeMillis(); + long endTime = startTime + SOCKET_CONNECT_TIMEOUT_MS; + + while (System.currentTimeMillis() < endTime && running.get()) { + synchronized (connectLock) { + if (connected.get()) { + return; + } + + if (exception[0] != null) { + throw exception[0]; + } + } + + try { + Thread.sleep(100); + } catch (InterruptedException e) { + connectThread.interrupt(); + throw e; + } + } + + synchronized (connectLock) { + if (!connected.get()) { + timedOut.set(true); + Log.e(TAG, "Socket connect timed out after " + SOCKET_CONNECT_TIMEOUT_MS + "ms"); + + try { + socket.close(); + } catch (IOException ignored) {} + + connectThread.interrupt(); + throw new IOException("Socket connect timed out"); + } + } + } + + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + private void waitForRetry() throws InterruptedException { + if (!running.get()) { + return; + } + + if (immediateRetry.getAndSet(false)) { + Log.d(TAG, "Immediate retry requested"); + wakeLockManager.acquire("retry", 60_000); + return; + } + + long delayMs = retryStrategy.nextDelayMs(); + + if (delayMs < 0) { + Log.d(TAG, "Retry strategy OFF, waiting for external trigger"); + waitForExternalRetry(); + return; + } + + Log.d(TAG, String.format("Waiting %dms before retry", delayMs)); + + if (delayMs > 60_000) { + useAlarmManagerForRetry(delayMs); + } else { + useThreadSleepForRetry(delayMs); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + private void useAlarmManagerForRetry(long delayMs) throws InterruptedException { + Log.d(TAG, "Using AlarmManager for retry delay"); + + if (bleDiscoverer != null && config.type == 5) { + bleDiscoverer.addDevice(btDevice, device -> { + Log.d(TAG, "BLE discovered device, triggering retry"); + signalRetry(); + }); + } + + long triggerTime = SystemClock.elapsedRealtime() + delayMs; + PendingIntent pendingIntent = createRetryPendingIntent(); + + alarmHelper.setExactAndAllowWhileIdle( + "WearRetry", + android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP, + triggerTime, + pendingIntent + ); + + wakeLockManager.release("retry-wait"); + + lock.lock(); + try { + while (running.get() && !immediateRetry.get()) { + retryCondition.await(); + } + immediateRetry.set(false); + } finally { + lock.unlock(); + } + + alarmHelper.cancel(pendingIntent); + + wakeLockManager.acquire("retry", 60_000); + } + + private void useThreadSleepForRetry(long delayMs) throws InterruptedException { + lock.lock(); + try { + long endTime = System.currentTimeMillis() + delayMs; + + while (running.get() && !immediateRetry.get()) { + long remaining = endTime - System.currentTimeMillis(); + if (remaining <= 0) break; + + retryCondition.await(remaining, java.util.concurrent.TimeUnit.MILLISECONDS); + } + + immediateRetry.set(false); + } finally { + lock.unlock(); + } + + wakeLockManager.acquire("retry", 60_000); + } + + private void waitForExternalRetry() { + Log.d(TAG, "Waiting for external retry trigger for " + config.address); + + wakeLockManager.release("wait-external"); + + lock.lock(); + try { + while (running.get() && !immediateRetry.get()) { + retryCondition.await(); + } + immediateRetry.set(false); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + lock.unlock(); + } + + wakeLockManager.acquire("external-retry", 60_000); + } + + private void signalRetry() { + lock.lock(); + try { + immediateRetry.set(true); + retryCondition.signal(); + } finally { + lock.unlock(); + } + } + + public void resetBackoffAndRetryConnection() { + Log.d(TAG, "Reset backoff and retry requested"); + retryStrategy.reset(); + signalRetry(); + } + + public void retryConnection(){ + Log.d(TAG, "Retry requested"); + signalRetry(); + } + + private PendingIntent createRetryPendingIntent() { + Intent intent = new Intent("com.google.android.gms.wearable.RETRY_CONNECTION"); + intent.setData(new Uri.Builder() + .scheme("wearable") + .authority(config.address) + .build()); + intent.setPackage(context.getPackageName()); + + return AlarmManagerHelper.createPendingIntent(context, 1, intent); + } + + private void closeSocket() { + isConnected = false; + + if (socket != null) { + try { + socket.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing socket", e); + } + socket = null; + } + + wearableConnection = null; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + private void cleanup() { + closeSocket(); + + if (bleDiscoverer != null) { + bleDiscoverer.removeDevice(btDevice); + } + + if (receiverRegistered) { + try { + context.unregisterReceiver(retryReceiver); + } catch (Exception e) { + Log.w(TAG, "Error unregistering receiver", e); + } + receiverRegistered = false; + } + + wakeLockManager.shutdown(); + } + + @Override + public void close(){ + Log.d(TAG, "Closing connection thread for " + config.address); + running.set(false); + signalRetry(); + interrupt(); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothGattHelper.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothGattHelper.java new file mode 100644 index 0000000000..b6e3de6bb2 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothGattHelper.java @@ -0,0 +1,17 @@ +package org.microg.gms.wearable.bluetooth; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; + +public interface BluetoothGattHelper { + boolean isConnected(); + + void connect(BluetoothDevice device); + void disconnect() throws BleException; + void discoverServices() throws BleException; + void refreshGatt(); + void setGattEventListener(GattEventListener listener); + + BluetoothGatt getGatt(); + void close(); +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothGattHelperImpl.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothGattHelperImpl.java new file mode 100644 index 0000000000..71cb669e99 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothGattHelperImpl.java @@ -0,0 +1,209 @@ +package org.microg.gms.wearable.bluetooth; + +import android.Manifest; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothManager; +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; + +import java.lang.reflect.Method; +import java.util.UUID; + +class BluetoothGattHelperImpl implements BluetoothGattHelper { + private static final String TAG = "BleGattHelperImpl"; + + static final UUID UUID_CTS = UUID.fromString("00001805-0000-1000-8000-00805f9b34fb"); + static final UUID UUID_CURRENT_TIME = UUID.fromString("00002a2b-0000-1000-8000-00805f9b34fb"); + static final UUID UUID_CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + private static final UUID UUID_SERVICE_CHANGED = UUID.fromString("00002a05-0000-1000-8000-00805f9b34fb"); + + private final Context context; + private final BluetoothManager btManager; + private volatile boolean connected; + + private final Object gattLock = new Object(); + + private volatile BluetoothGatt gatt; + private volatile GattEventListener listener; + + BluetoothGattHelperImpl(Context context, BluetoothManager btManager) { + this.context = context; + this.btManager = btManager; + } + + @Override + public void setGattEventListener(GattEventListener l) { + this.listener = l; + } + + @Override + public boolean isConnected() { + return connected; + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void connect(BluetoothDevice device) { + synchronized (gattLock) { + if (gatt != null) { + Log.w(TAG, "connect: called while GATT exists — closing previous"); + gatt.close(); + gatt = null; + connected = false; + } + gatt = createGatt(device); + Log.d(TAG, "connectGatt: issued for " + device.getAddress()); + } + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void disconnect() throws BleException { + BluetoothGatt g = gatt; + if (g == null) throw new BleException("not connected"); + g.disconnect(); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void discoverServices() throws BleException { + BluetoothGatt g = gatt; + if (g == null) throw new BleException("not connected"); + if (!g.discoverServices()) { + throw new BleException("discoverServices: returned false"); + } + } + + @Override + public void refreshGatt() { + BluetoothGatt g = gatt; + if (g == null) { Log.w(TAG, "refreshGatt: not connected"); return; } + try { + Method refresh = g.getClass().getMethod("refresh"); + refresh.invoke(g); + Log.d(TAG, "refreshGatt: OK"); + } catch (Exception e) { + Log.w(TAG, "refreshGatt failed: " + e); + } + } + + @Override + public BluetoothGatt getGatt() { + return gatt; + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void close() { + synchronized (gattLock) { + if (gatt != null) { + gatt.close(); + gatt = null; + } + connected = false; + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private BluetoothGatt createGatt(BluetoothDevice device) { + try { + Method m = BluetoothDevice.class.getMethod("connectGatt", + Context.class, boolean.class, BluetoothGattCallback.class, int.class); + m.setAccessible(true); + BluetoothGatt g = (BluetoothGatt) m.invoke(device, context, false, + callback, 2); + Log.d(TAG, "connectGatt via reflection (TRANSPORT_LE)"); + return g; + } catch (Exception e) { + Log.w(TAG, "connectGatt reflection failed (" + + e.getMessage() + "); falling back to 3-arg"); + return device.connectGatt(context, false, callback); + } + } + + + private final BluetoothGattCallback callback = new BluetoothGattCallback() { + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + Log.d(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + + GattEventListener l = listener; + if (newState == BluetoothGatt.STATE_CONNECTED) { + connected = true; + if (l != null) l.onGattConnected(); + } else if (newState == BluetoothGatt.STATE_DISCONNECTED) { + connected = false; + g.close(); + if (gatt == g) gatt = null; + if (l != null) l.onGattDisconnected(); + } + } + + @Override + public void onServicesDiscovered(BluetoothGatt g, int status) { + Log.d(TAG, "onServicesDiscovered status=" + status); + GattEventListener l = listener; + if (l == null) return; + if (status == BluetoothGatt.GATT_SUCCESS) { + l.onServicesDiscovered(); + } + } + + @Override + public void onServiceChanged(BluetoothGatt g) { + Log.d(TAG, "onServiceChanged"); + GattEventListener l = listener; + if (l != null) l.onServiceChanged(); + } + + @Override + public void onCharacteristicChanged(BluetoothGatt g, + BluetoothGattCharacteristic c, byte[] value) { + GattEventListener l = listener; + if (l == null) return; + + if (UUID_SERVICE_CHANGED.equals(c.getUuid())) { + l.onServiceChanged(); + } else { + l.onCharacteristicChanged(c); + } + } + + @Override + public void onCharacteristicChanged(BluetoothGatt g, + BluetoothGattCharacteristic c) { + GattEventListener l = listener; + if (l == null) return; + + if (UUID_SERVICE_CHANGED.equals(c.getUuid())) { + l.onServiceChanged(); + } else { + l.onCharacteristicChanged(c); + } + } + + @Override + public void onCharacteristicWrite(BluetoothGatt g, + BluetoothGattCharacteristic c, int status) { + GattEventListener l = listener; + if (l != null) l.onCharacteristicWritten(c); + } + + @Override + public void onDescriptorWrite(BluetoothGatt g, + BluetoothGattDescriptor d, int status) { + } + }; +} + diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothServer.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothServer.java new file mode 100644 index 0000000000..d8f2e7560e --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothServer.java @@ -0,0 +1,375 @@ +/* + * SPDX-FileCopyrightText: 2015, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable.bluetooth; + +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothServerSocket; +import android.bluetooth.BluetoothSocket; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; +import android.provider.Settings; +import android.util.Log; + +import androidx.annotation.RequiresPermission; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.ConnectHandshake; +import org.microg.gms.wearable.TransportConnectionHandler; +import org.microg.gms.wearable.WearableImpl; +import org.microg.gms.wearable.WearableConnection; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.Closeable; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class BluetoothServer implements Closeable { + private static final String TAG = "GmsWearBtServer"; + + // Use the standard Wear OS UUID + private static final UUID WEAR_UUID = UUID.fromString("5e8945b0-9525-11e3-a5e2-0800200c9a66"); + + private final Context context; + private final WearableImpl wearable; + private final BluetoothAdapter bluetoothAdapter; + private final Map servers = new HashMap<>(); + private final BroadcastReceiver bluetoothStateReceiver; + + public BluetoothServer(Context context, WearableImpl wearable, BluetoothAdapter bluetoothAdapter) { + this.context = context; + this.wearable = wearable; + this.bluetoothAdapter = bluetoothAdapter; + + this.bluetoothStateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(intent.getAction())) { + int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF); + onBluetoothAdapterStateChanged(state); + } + } + }; + + context.registerReceiver(bluetoothStateReceiver, + new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)); + + Log.d(TAG, "BluetoothServerManager initialized"); + } + + /** + * Add a Bluetooth server configuration + */ + public void addConfiguration(ConnectionConfiguration config) { + validateConfiguration(config); + + String name = config.name != null ? config.name : "WearServer"; + Log.d(TAG, "Adding Bluetooth server configuration: " + name); + + if (servers.containsKey(name)) { + Log.d(TAG, "Server already exists: " + name); + return; + } + + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) { + Log.w(TAG, "Bluetooth not available, deferring server start"); + return; + } + + startServer(config); + } + + /** + * Remove a Bluetooth server configuration + */ + public void removeConfiguration(ConnectionConfiguration config) { + validateConfiguration(config); + + String name = config.name != null ? config.name : "WearServer"; + Log.d(TAG, "Removing Bluetooth server configuration: " + name); + + BluetoothServerThread server = servers.get(name); + if (server != null) { + server.close(); + servers.remove(name); + } + } + + private void startServer(ConnectionConfiguration config) { + String name = config.name != null ? config.name : "WearServer"; + + if (servers.containsKey(name)) { + Log.d(TAG, "Server already running: " + name); + return; + } + + Log.d(TAG, "Starting Bluetooth server: " + name); + BluetoothServerThread server = new BluetoothServerThread(context, wearable, config, bluetoothAdapter); + servers.put(name, server); + server.start(); + } + + private void onBluetoothAdapterStateChanged(int state) { + Log.d(TAG, "Bluetooth adapter state changed to " + state); + + if (state == BluetoothAdapter.STATE_OFF) { + // Bluetooth turned off, close all servers + Log.d(TAG, "Closing all Bluetooth servers due to adapter off"); + for (BluetoothServerThread server : servers.values()) { + server.close(); + } + servers.clear(); + } + // Note: We don't auto-restart servers on STATE_ON + // The user/system must explicitly re-enable the configuration + } + + private static void validateConfiguration(ConnectionConfiguration config) { + if (config == null) { + throw new IllegalArgumentException("Invalid configuration: null"); + } + + int type = config.type; + if (type != WearableImpl.TYPE_BLUETOOTH_RFCOMM && type != 5) { + throw new IllegalArgumentException("Invalid connection type for Bluetooth server: " + type); + } + + if (config.role != WearableImpl.ROLE_SERVER) { + throw new IllegalArgumentException("Invalid role for server: " + config.role); + } + } + + @Override + public void close() { + Log.d(TAG, "Closing BluetoothServerManager"); + + try { + context.unregisterReceiver(bluetoothStateReceiver); + } catch (Exception e) { + Log.w(TAG, "Error unregistering receiver", e); + } + + for (BluetoothServerThread server : servers.values()) { + server.close(); + } + servers.clear(); + } + + /** + * Individual server thread that accepts incoming connections + */ + private static class BluetoothServerThread extends Thread implements Closeable { + private static final String TAG = "GmsWearBtSrvThread"; + private static final int MAX_RETRY_COUNT = 3; + private static final int RETRY_DELAY_MS = 5000; + + private final Context context; + private final ConnectionConfiguration config; + private final BluetoothAdapter bluetoothAdapter; + private volatile boolean running = true; + private BluetoothServerSocket serverSocket; + private final WearableImpl wearable; + private int retryCount = 0; + + public BluetoothServerThread(Context context, WearableImpl wearable, ConnectionConfiguration config, BluetoothAdapter adapter) { + super("BtServerThread-" + (config.name != null ? config.name : "default")); + this.context = context; + this.wearable = wearable; + this.config = config; + this.bluetoothAdapter = adapter; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + @Override + public void run() { + String name = config.name != null ? config.name : "WearServer"; + Log.d(TAG, "Bluetooth server thread started: " + name); + + while (running && !isInterrupted()) { + try { + // Create server socket + if (serverSocket == null) { + createServerSocket(); + } + + if (serverSocket != null) { + acceptConnection(); + retryCount = 0; // Reset on successful accept + } + + } catch (IOException e) { + Log.w(TAG, "Server socket error: " + e.getMessage()); + closeServerSocket(); + + if (running && retryCount < MAX_RETRY_COUNT) { + retryCount++; + Log.d(TAG, "Retrying server socket creation (attempt " + retryCount + "/" + MAX_RETRY_COUNT + ")"); + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException ie) { + break; + } + } else if (retryCount >= MAX_RETRY_COUNT) { + Log.e(TAG, "Max retry count reached, stopping server"); + break; + } + } + } + + closeServerSocket(); + Log.d(TAG, "Bluetooth server thread stopped: " + name); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void createServerSocket() throws IOException { + String name = config.name != null ? config.name : "WearServer"; + + if (!running || bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) { + throw new IOException("Bluetooth not available"); + } + + Log.d(TAG, "Creating server socket for " + name + " via " + getConnectionTypeName()); + + if (config.type != WearableImpl.TYPE_BLUETOOTH_RFCOMM && config.type != 5) { + return; + } + serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord(name, WEAR_UUID); + Log.d(TAG, "RFCOMM server socket created on UUID: " + WEAR_UUID); + + } + + private void acceptConnection() throws IOException { + if (serverSocket == null) { + throw new IOException("Server socket is null"); + } + + Log.d(TAG, "Waiting for incoming connection..."); + + // This blocks until a connection is made + BluetoothSocket clientSocket = serverSocket.accept(); + + if (clientSocket != null) { + Log.d(TAG, "Client connected from: " + clientSocket.getRemoteDevice().getAddress()); + handleConnection(clientSocket); + } + } + + private void handleConnection(BluetoothSocket clientSocket) { + // Spawn a new thread to handle this connection + // so we can go back to accepting new connections + new Thread(() -> { + try { + Log.d(TAG, "Handling connection from " + clientSocket.getRemoteDevice().getAddress()); + + String localNodeId = wearable.getLocalNodeId(); + + BluetoothWearableConnection connection = new BluetoothWearableConnection( + clientSocket, + new ConnectHandshake.LocalIdentity( + wearable.getLocalNodeId(), + Build.MODEL, + getAndroidId(), + wearable.getClockworkNodePreferences().getNetworkId(), + null, + config.migrating, + config.migrating + ? wearable.getClockworkNodePreferences().getPeerNodeId() + : null), + WearableConnection.NOOP); + + if (!connection.handshake()) { + Log.w(TAG, "Handshake failed for " + + clientSocket.getRemoteDevice().getAddress()); + return; + } + + new TransportConnectionHandler(wearable, config).handle(connection); + + } catch (IOException e) { + Log.w(TAG, "Error handling connection: " + e.getMessage()); + } finally { + try { + clientSocket.close(); + } catch (IOException ignored) {} + } + }, "BtServerConn-" + clientSocket.getRemoteDevice().getAddress()).start(); + } + + + private long getAndroidId() { + try { + String s = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + return s != null ? Long.parseUnsignedLong(s, 16) : 0L; + } catch (NumberFormatException e) { + return 0L; + } + } + + private void closeServerSocket() { + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing server socket", e); + } + serverSocket = null; + } + } + + private String getConnectionTypeName() { + if (config.type == WearableImpl.TYPE_BLUETOOTH_RFCOMM) { + return "RFCOMM"; + } else if (config.type == 5) { + return "RFCOMM maybe"; + } + return "Unknown"; + } + + @Override + public void close() { + Log.d(TAG, "Closing Bluetooth server"); + running = false; + interrupt(); + closeServerSocket(); + } + + private static class ServerConnectionListener implements WearableConnection.Listener { + private final Context context; + private final ConnectionConfiguration config; + private final BluetoothSocket socket; + + public ServerConnectionListener(Context context, ConnectionConfiguration config, BluetoothSocket socket) { + this.context = context; + this.config = config; + this.socket = socket; + } + + @Override + public void onConnected(WearableConnection connection) { + Log.d(TAG, "Server connection established with " + socket.getRemoteDevice().getAddress()); + // TODO: Notify WearableImpl about connection + } + + @Override + public void onMessage(WearableConnection connection, RootMessage message) { + Log.d(TAG, "Server received message from " + socket.getRemoteDevice().getAddress()); + // TODO: Handle incoming messages + } + + @Override + public void onDisconnected() { + Log.d(TAG, "Server connection disconnected from " + socket.getRemoteDevice().getAddress()); + // TODO: Notify WearableImpl about disconnection + } + } + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothWearableConnection.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothWearableConnection.java new file mode 100644 index 0000000000..33e2f50860 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothWearableConnection.java @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2015, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable.bluetooth; + +import android.bluetooth.BluetoothSocket; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; + +import org.microg.gms.profile.Build; +import org.microg.gms.wearable.ConnectHandshake; +import org.microg.gms.wearable.WearableConnection; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.MessagePiece; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +public class BluetoothWearableConnection extends WearableConnection { + private static final String TAG = "BtWearableConnection"; + private static final long HANDSHAKE_TIMEOUT_MS = 30000; + private static final long WRITE_STUCK_TIMEOUT_MS = 15000; + private final int MAX_PIECE_SIZE = DEFAULT_MAX_PIECE_SIZE + 4096; + private final BluetoothSocket socket; + private final DataInputStream is; + private final DataOutputStream os; + private final Listener listener; + private final String localNodeId; + private final AtomicBoolean isClosed = new AtomicBoolean(false); + private final HandlerThread watchdogThread; + private final Handler watchdogHandler; + private final Runnable writeStuckRunnable; + private final long androidId; + private final ConnectHandshake.LocalIdentity localIdentity; + private String peerNodeId; + private boolean handshakeComplete = false; + private volatile Thread readerThread; + + public BluetoothWearableConnection(BluetoothSocket socket, ConnectHandshake.LocalIdentity localIdentity, Listener listener) throws IOException { + super(listener); + this.socket = socket; + this.is = new DataInputStream(socket.getInputStream()); + this.os = new DataOutputStream(socket.getOutputStream()); + this.localIdentity = localIdentity; + this.localNodeId = localIdentity.nodeId; + this.listener = listener; + this.androidId = localIdentity.androidId; + + this.watchdogThread = new HandlerThread("BtWatchdog-" + localNodeId); + this.watchdogThread.start(); + this.watchdogHandler = new Handler(watchdogThread.getLooper()); + + this.writeStuckRunnable = () -> { + if (isClosed.get()) + return; + + Log.e(TAG, "Write stuck for more than " + WRITE_STUCK_TIMEOUT_MS + + "ms - closing socket"); + isClosed.set(true); + try { + socket.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing socket on write-stuck", e); + } + }; + } + + public boolean handshake() { + Log.d(TAG, "Starting handshake, local node ID: " + localNodeId); + + final AtomicBoolean timedOut = new AtomicBoolean(false); + + Runnable timeoutWatchdog = () -> { + if (!handshakeComplete) { + Log.e(TAG, "Handshake timeout after " + HANDSHAKE_TIMEOUT_MS + "ms - forcing close"); + timedOut.set(true); + try { + socket.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing socket on timeout", e); + } + } + }; + + watchdogHandler.postDelayed(timeoutWatchdog, HANDSHAKE_TIMEOUT_MS); + + try { + Connect peer = ConnectHandshake.perform(this, localIdentity); + + if (timedOut.get()) { + return false; + } + setPeerConnect(peer); + this.peerNodeId = peer.id; + handshakeComplete = true; + return true; + + } catch (IOException e) { + if (timedOut.get()) { + Log.e(TAG, "Handshake failed due to timeout", e); + } else { + Log.e(TAG, "Handshake failed", e); + } + return false; + } finally { + watchdogHandler.removeCallbacks(timeoutWatchdog); + } + } + + public String getPeerNodeId() { + return peerNodeId; + } + + public String getLocalNodeId() { + return localNodeId; + } + + public boolean isHandshakeComplete() { + return handshakeComplete; + } + + protected void writeMessagePiece(MessagePiece piece) throws IOException { + byte[] bytes = MessagePiece.ADAPTER.encode(piece); + + synchronized (os) { + if (isClosed.get()) { + throw new IOException("Socket not connected"); + } + watchdogHandler.postDelayed(writeStuckRunnable, WRITE_STUCK_TIMEOUT_MS); + + try { + os.writeInt(bytes.length); + os.write(bytes); + os.flush(); + } catch (IOException e) { + Log.e(TAG, "Write failed, socket may be closed", e); + throw e; + } finally { + watchdogHandler.removeCallbacks(writeStuckRunnable); + } + } + } + + @Override + public void run() { + readerThread = Thread.currentThread(); + + try { + // Perform handshake first + if (!handshake()) { + Log.e(TAG, "Handshake failed, closing connection"); + try { + close(); + } catch (IOException e) { + Log.e(TAG, "Error closing connection after handshake failure", e); + } + return; + } + + super.run(); + + } catch (Exception e) { + Log.e(TAG, "Error in connection run loop", e); + } finally { + readerThread = null; + } + } + + protected MessagePiece readMessagePiece() throws IOException { + if (isClosed.get()) { + throw new IOException("Socket not connected"); + } + + try { + int len = is.readInt(); + + if (len <= 0 || len > MAX_PIECE_SIZE) { + throw new IOException("Invalid piece length: " + len); + } + + byte[] bytes = new byte[len]; + + try { + is.readFully(bytes); + } catch (EOFException e) { + throw new IOException("Socket closed by peer while reading data", e); + } + + return MessagePiece.ADAPTER.decode(bytes); + } catch (IOException e) { + if (isClosed.get()) { + throw new IOException("Connection closed during read", e); + } + + String msg = e.getMessage(); + if (msg != null && msg.contains("bt socket closed")) { + Log.d(TAG, "Bluetooth socket closed during read"); + isClosed.set(true); + } + + throw new IOException("Connection closed by peer", e); + } + } + + @Override + public void close() throws IOException { + if (isClosed.getAndSet(true)) { + Log.d(TAG, "Connection already closed"); + return; + } + + Log.d(TAG, "Closing Bluetooth wearable connection"); + + watchdogHandler.removeCallbacksAndMessages(null); + watchdogThread.quitSafely(); + + Thread reader = readerThread; + if (reader != null && reader != Thread.currentThread()) { + reader.interrupt(); + } + + IOException exception = null; + + try { + if (is != null) { + is.close(); + } + } catch (IOException e) { + Log.w(TAG, "Error closing input stream", e); + exception = e; + } + + try { + if (os != null) { + os.close(); + } + } catch (IOException e) { + Log.w(TAG, "Error closing output stream", e); + if (exception == null) exception = e; + } + + try { + socket.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing socket", e); + if (exception == null) exception = e; + } + + if (exception != null) { + throw exception; + } + } + + public boolean isClosed() { + return isClosed.get(); + } + + @Override + public String getRemoteAddress() { + return socket.getRemoteDevice().getAddress(); + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/GattEventListener.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/GattEventListener.java new file mode 100644 index 0000000000..e0335086e2 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/GattEventListener.java @@ -0,0 +1,14 @@ +package org.microg.gms.wearable.bluetooth; + +import android.bluetooth.BluetoothGattCharacteristic; + +public interface GattEventListener { + void onCharacteristicChanged(BluetoothGattCharacteristic characteristic); + void onServiceChanged(); + void onCharacteristicWritten(BluetoothGattCharacteristic characteristic); + void onServicesDiscovered(); + + default void onGattConnected() {} + + default void onGattDisconnected() {} +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/RetryStrategy.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/RetryStrategy.java new file mode 100644 index 0000000000..fb1b0eb416 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/RetryStrategy.java @@ -0,0 +1,156 @@ +package org.microg.gms.wearable.bluetooth; + +import android.util.Log; + +public class RetryStrategy { + private static final String TAG = "RetryStrategy"; + + public static final int POLICY_DEFAULT = 0; + public static final int POLICY_AGGRESSIVE = 1; + public static final int POLICY_LOW_POWER = 2; + public static final int POLICY_OFF = 3; + + private static final RetryParams DEFAULT_PARAMS = new RetryParams( + 6,30000L, 320000L); + + private static final RetryParams AGGRESSIVE_PARAMS = new RetryParams( + 6,30000L, 320000L); + + private static final RetryParams LOW_POWER_PARAMS = new RetryParams( + 10,600000L, 1024000L); + + private static final RetryParams OFF_PARAMS = new RetryParams( + 0,0L, -1L); + + private final int maxRetryStep; + private final long totalRetryTimeLimitMs; + private final long retryDelayAtLimitMs; + + private long currentRetryCount = 0; + private long cumulativeDelayMs = 0; + private long lastResetTime = System.currentTimeMillis(); + + public static RetryStrategy fromPolicy(int policy) { + RetryParams params; + switch (policy) { + case POLICY_AGGRESSIVE: + params = AGGRESSIVE_PARAMS; + break; + case POLICY_LOW_POWER: + params = LOW_POWER_PARAMS; + break; + case POLICY_OFF: + params = OFF_PARAMS; + break; + case POLICY_DEFAULT: + default: + params = DEFAULT_PARAMS; + break; + } + + Log.d(TAG, String.format("Created retry strategy: policy=%s, params=%s", + policyToString(policy), params)); + + return new RetryStrategy(params.maxRetryStep, params.totalRetryTimeLimit, + params.retryDelayAtLimit); + } + + public RetryStrategy(int maxRetryStep, long totalRetryTimeLimitMs, long retryDelayAtLimitMs) { + this.maxRetryStep = maxRetryStep; + this.totalRetryTimeLimitMs = totalRetryTimeLimitMs; + this.retryDelayAtLimitMs = retryDelayAtLimitMs; + } + + public long nextDelayMs() { + if (retryDelayAtLimitMs < 0) { + Log.d(TAG, "Retry strategy is OFF, returning -1"); + return -1; + } + + long retryCount = Math.min(maxRetryStep, currentRetryCount + 1); + currentRetryCount = retryCount; + + // exponential increase + long delay = (1L << (int)(retryCount - 1)) * 1000L; + + long newCumulativeMs = cumulativeDelayMs + delay; + cumulativeDelayMs = newCumulativeMs; + + Log.d(TAG, String.format("nextDelay: retryCount=%d, delay=%dms, cumulative=%dms/%dms", + retryCount, delay, newCumulativeMs, totalRetryTimeLimitMs)); + + if (totalRetryTimeLimitMs >= 0 && newCumulativeMs >= totalRetryTimeLimitMs) { + Log.w(TAG, String.format( + "Cumulative retry time limit exceeded (%dms >= %dms), returning fallback delay: %dms", + newCumulativeMs, totalRetryTimeLimitMs, retryDelayAtLimitMs)); + + return retryDelayAtLimitMs; + } + + return delay; + } + + public void disableRetries() { + Log.d(TAG, "Disabling retries"); + currentRetryCount = maxRetryStep; + cumulativeDelayMs = Math.max(cumulativeDelayMs, totalRetryTimeLimitMs); + } + + public void reset() { + Log.d(TAG, String.format("Resetting retry state (was: retryCount=%d, cumulative=%dms)", + currentRetryCount, cumulativeDelayMs)); + + currentRetryCount = 0; + cumulativeDelayMs = 0; + lastResetTime = System.currentTimeMillis(); + } + + public boolean isEnabled() { + return retryDelayAtLimitMs >= 0; + } + + public boolean hasExceededLimit() { + return totalRetryTimeLimitMs >= 0 && cumulativeDelayMs >= totalRetryTimeLimitMs; + } + + public long getRetryCount() { + return currentRetryCount; + } + + public long getCumulativeDelayMs() { + return cumulativeDelayMs; + } + + public long getTimeSinceResetMs() { + return System.currentTimeMillis() - lastResetTime; + } + + private static String policyToString(int policy) { + switch (policy) { + case POLICY_DEFAULT: return "DEFAULT"; + case POLICY_AGGRESSIVE: return "AGGRESSIVE"; + case POLICY_LOW_POWER: return "LOW_POWER"; + case POLICY_OFF: return "OFF"; + default: return "UNKNOWN(" + policy + ")"; + } + } + + private static class RetryParams { + final int maxRetryStep; + final long totalRetryTimeLimit; + final long retryDelayAtLimit; + + RetryParams(int maxRetryStep, long totalRetryTimeLimit, long retryDelayAtLimit) { + this.maxRetryStep = maxRetryStep; + this.totalRetryTimeLimit = totalRetryTimeLimit; + this.retryDelayAtLimit = retryDelayAtLimit; + } + + @Override + public String toString() { + return "RetryParams{maxStep=" + maxRetryStep + + ", timeLimit=" + totalRetryTimeLimit + + ", fallback=" + retryDelayAtLimit + "}"; + } + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/WakeLockManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/WakeLockManager.java new file mode 100644 index 0000000000..1543e7604f --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/WakeLockManager.java @@ -0,0 +1,214 @@ +package org.microg.gms.wearable.bluetooth; + +import android.content.Context; +import android.os.PowerManager; +import android.util.Log; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class WakeLockManager { + private static final String TAG = "WakeLockManager"; + private static final long DEFAULT_TIMEOUT_MS = 5 * 60 * 1000L; + private static final long MAX_TIMEOUT_MS = 10 * 60 * 1000L; + + private final Context context; + private final PowerManager.WakeLock wakeLock; + private final Object lock = new Object(); + private final ScheduledExecutorService executor; + + private final AtomicInteger refCount = new AtomicInteger(0); + private final Map tagCounts = new HashMap<>(); + + private ScheduledFuture timeoutFuture; + private long acquireTimeMs = 0; + private long maxTimeoutMs = MAX_TIMEOUT_MS; + + private int totalAcquires = 0; + private int totalReleases = 0; + private int forceReleaseCount = 0; + private boolean isForceReleased = false; + + private volatile boolean isEnabled = true; + + public WakeLockManager(Context context, String tag, ScheduledExecutorService executor) { + this.context = context; + this.executor = executor; + + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (pm == null) { + throw new IllegalStateException("PowerManager not available"); + } + + this.wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "GmsWear:" + tag + ); + this.wakeLock.setReferenceCounted(false); + } + + public void acquire() { + acquire(null, DEFAULT_TIMEOUT_MS); + } + + public void acquire(long timeoutMs) { + acquire(null, timeoutMs); + } + + public void acquire(String tag, long timeoutMs) { + synchronized (lock) { + if (!isEnabled) { + Log.w(TAG, "Wake lock disabled, ignoring acquire request"); + return; + } + + int count = refCount.incrementAndGet(); + totalAcquires++; + + if (tag != null) { + Integer tagCount = tagCounts.get(tag); + tagCounts.put(tag, tagCount == null ? 1 : tagCount + 1); + } + + Log.d(TAG, String.format("acquire(tag=%s, timeout=%dms) refCount=%d", + tag, timeoutMs, count)); + + if (count == 1) { + try { + wakeLock.acquire(DEFAULT_TIMEOUT_MS); + acquireTimeMs = System.currentTimeMillis(); + isForceReleased = false; + + Log.d(TAG, "Wake lock acquired (first reference)"); + } catch (Exception e) { + Log.e(TAG, "Failed to acquire wake lock", e); + refCount.decrementAndGet(); + throw e; + } + } + + if (timeoutMs > 0) { + long effectiveTimeout = Math.min(timeoutMs, MAX_TIMEOUT_MS); + + if (effectiveTimeout > maxTimeoutMs) { + maxTimeoutMs = effectiveTimeout; + scheduleTimeout(effectiveTimeout); + } + } + } + } + + public void release() { + release(null); + } + + public void release(String tag) { + synchronized (lock) { + int count = refCount.get(); + + if (count <= 0) { + Log.w(TAG, String.format("release(tag=%s) called but refCount=%d (already released)", + tag, count)); + return; + } + + count = refCount.decrementAndGet(); + totalReleases++; + + if (tag != null) { + Integer tagCount = tagCounts.get(tag); + if (tagCount != null) { + if (tagCount == 1) { + tagCounts.remove(tag); + } else { + tagCounts.put(tag, tagCount - 1); + } + } + } + + Log.d(TAG, String.format("release(tag=%s) refCount=%d", tag, count)); + + if (count == 0) { + doRelease(); + } + } + } + + public void forceRelease() { + synchronized (lock) { + int count = refCount.get(); + if (count > 0) { + Log.w(TAG, String.format("Force releasing wake lock (refCount=%d)", count)); + refCount.set(0); + tagCounts.clear(); + isForceReleased = true; + forceReleaseCount++; + doRelease(); + } + } + } + + public void setEnabled(boolean enabled) { + synchronized (lock) { + this.isEnabled = enabled; + if (!enabled) { + forceRelease(); + } + } + } + + public boolean isHeld() { + synchronized (lock) { + return refCount.get() > 0; + } + } + + public int getRefCount() { + return refCount.get(); + } + + private void doRelease() { + try { + if (timeoutFuture != null) { + timeoutFuture.cancel(false); + timeoutFuture = null; + } + + if (wakeLock.isHeld()) { + wakeLock.release(); + long heldDurationMs = System.currentTimeMillis() - acquireTimeMs; + Log.d(TAG, String.format("Wake lock released (held for %dms)", heldDurationMs)); + } + + maxTimeoutMs = MAX_TIMEOUT_MS; + acquireTimeMs = 0; + + } catch (Exception e) { + Log.e(TAG, "Error releasing wake lock", e); + } + } + + private void scheduleTimeout(long timeoutMs) { + if (timeoutFuture != null) { + timeoutFuture.cancel(false); + } + + timeoutFuture = executor.schedule(() -> { + Log.w(TAG, "Wake lock timeout - force releasing"); + forceRelease(); + }, timeoutMs, TimeUnit.MILLISECONDS); + + Log.d(TAG, String.format("Scheduled timeout in %dms", timeoutMs)); + } + + public void shutdown() { + synchronized (lock) { + isEnabled = false; + forceRelease(); + } + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelAssetApiEnum.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelAssetApiEnum.java new file mode 100644 index 0000000000..f79ab55657 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelAssetApiEnum.java @@ -0,0 +1,24 @@ +package org.microg.gms.wearable.channel; + +public enum ChannelAssetApiEnum { + ORIGIN_CHANNEL_API(0), + ORIGIN_LARGE_ASSET_API(1); + + public final int id; + + ChannelAssetApiEnum(int id) { + this.id = id; + } + + public static ChannelAssetApiEnum fromId(int id) { + for (ChannelAssetApiEnum v : values()) { + if (v.id == id) return v; + } + return ORIGIN_CHANNEL_API; + } + + @Override + public String toString() { + return Integer.toString(id); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelCallbacks.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelCallbacks.java new file mode 100644 index 0000000000..05d44d6e7d --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelCallbacks.java @@ -0,0 +1,8 @@ +package org.microg.gms.wearable.channel; + +public interface ChannelCallbacks { + void onChannelOpened(ChannelToken token, String path); + void onChannelClosed(ChannelToken token, String path, int closeReason, int errorCode); + void onChannelInputClosed(ChannelToken token, String path, int closeReason, int errorCode); + void onChannelOutputClosed(ChannelToken token, String path, int closeReason, int errorCode); +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelException.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelException.java new file mode 100644 index 0000000000..4c786e8dc3 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelException.java @@ -0,0 +1,24 @@ +package org.microg.gms.wearable.channel; + +public class ChannelException extends Exception { + private final ChannelToken token; + + public ChannelException(ChannelToken token) { + super("Channel exception for: " + token); + this.token = token; + } + + public ChannelException(ChannelToken token, String message) { + super(message); + this.token = token; + } + + public ChannelException(ChannelToken token, Throwable cause) { + super("Channel exception for: " + token, cause); + this.token = token; + } + + public ChannelToken getToken() { + return token; + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelManager.java new file mode 100644 index 0000000000..f140dead18 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelManager.java @@ -0,0 +1,719 @@ +package org.microg.gms.wearable.channel; + +import android.os.Handler; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; + +import com.google.android.gms.wearable.internal.ChannelReceiveFileResponse; +import com.google.android.gms.wearable.internal.ChannelSendFileResponse; +import com.google.android.gms.wearable.internal.GetChannelInputStreamResponse; +import com.google.android.gms.wearable.internal.GetChannelOutputStreamResponse; +import com.google.android.gms.wearable.internal.IWearableCallbacks; + +import org.microg.gms.wearable.WearableConnection; +import org.microg.gms.wearable.WearableImpl; +import org.microg.gms.wearable.proto.AppKey; +import org.microg.gms.wearable.proto.ChannelControlRequest; +import org.microg.gms.wearable.proto.ChannelDataAckRequest; +import org.microg.gms.wearable.proto.ChannelDataHeader; +import org.microg.gms.wearable.proto.ChannelDataRequest; +import org.microg.gms.wearable.proto.ChannelRequest; +import org.microg.gms.wearable.proto.Request; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.Random; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import okio.ByteString; + +public class ChannelManager { + private static final String TAG = "ChannelManager"; + + private static final int PROCESSING_LOOP_DELAY_MS = 10; + private static final long OPEN_TIMEOUT_MS = 15000; + + public static final int CHANNEL_CONTROL_TYPE_OPEN = 1; + public static final int CHANNEL_CONTROL_TYPE_OPEN_ACK = 2; + public static final int CHANNEL_CONTROL_TYPE_CLOSE = 3; + + public static final int CHANNEL_ORIGIN_CHANNEL_API = 0; + public static final int CHANNEL_REQ_VERSION = 0; + + private final Handler handler; + private final WearableImpl wearable; + private final String localNodeId; + private final Random random; + private final ChannelTransport transport; + + public final ChannelTable channelTable = new ChannelTable(); + private final AtomicBoolean isRunning = new AtomicBoolean(false); + private final BlockingQueue taskQueue = new LinkedBlockingQueue<>(); + + private final AtomicInteger requestIdCounter = new AtomicInteger(1); + private final AtomicInteger generationCounter = new AtomicInteger(1); + + private ChannelCallbacks channelCallbacks; + + private static int CONNECT_RETRY_MAX = 5; + private static long CONNECT_RETRY_MS = 500; + + private volatile long cooldownUntil = 0; + private final Object callbacksLock = new Object(); + private final EnumMap callbacksMap = + new EnumMap<>(ChannelAssetApiEnum.class); + public final TrustedPeersService trustedPeers; + + private final Runnable processingLoop = new Runnable() { + @Override + public void run() { + if (!isRunning.get()) { + return; + } + + try { + ChannelTask task; + while ((task = taskQueue.poll()) != null) { + task.run(); + } + + for (ChannelStateMachine channel : channelTable.values()) { + try { + processChannelIO(channel); + } catch (Exception e) { + Log.e(TAG, "Error processing channel I/O", e); + try { + channel.forceClose(); + channelTable.remove(channel.token); + } catch (Exception ex) { + Log.e(TAG, "Error during cleanup", ex); + } + } + } + + } catch (Exception e) { + Log.e(TAG, "Error in processing loop", e); + } + + if (isRunning.get()) { + handler.postDelayed(this, PROCESSING_LOOP_DELAY_MS); + } + } + }; + + public ChannelManager(Handler handler, WearableImpl wearable, String localNodeId, TrustedPeersService trustedPeers) { + this.handler = handler; + this.wearable = wearable; + this.localNodeId = localNodeId; + this.random = new Random(); + this.transport = new ChannelTransport(); + this.trustedPeers = trustedPeers; + } + + public void setCallbacks(ChannelAssetApiEnum origin, ChannelCallbacks callbacks) { + synchronized (callbacksLock) { + if (callbacks == null) { + callbacksMap.remove(origin); + } else { + if (callbacksMap.containsKey(origin)) { + throw new IllegalStateException( + "setCallbacks called twice for the same origin: " + origin); + } + callbacksMap.put(origin, callbacks); + } + } + } + + public ChannelCallbacks getCallbacks(ChannelAssetApiEnum origin) { + synchronized (callbacksLock) { + ChannelCallbacks cb = callbacksMap.get(origin); + if (cb == null) { + throw new IllegalStateException("No callbacks set for origin: " + origin); + } + return cb; + } + } + + public ChannelCallbacks getCallbacksOrNull(ChannelAssetApiEnum origin) { + synchronized (callbacksLock) { + return callbacksMap.get(origin); + } + } + + public void setOperationCooldown(long durationMs) { + cooldownUntil = System.currentTimeMillis() + durationMs; + Log.d(TAG, "Operation cooldown set for " + durationMs + "ms"); + } + + private boolean isInCooldown() { + long now = System.currentTimeMillis(); + if (now < cooldownUntil) { + long remaining = cooldownUntil - now; + Log.d(TAG, "In cooldown period, " + remaining + "ms remaining"); + return true; + } + return false; + } + + public boolean isRunning() { + return isRunning.get(); + } + + public void start() { + if (isRunning.compareAndSet(false, true)) { + Log.d(TAG, "ChannelManager started, localNodeId=" + localNodeId); + handler.post(processingLoop); + } + } + + public void stop() { + if (isRunning.compareAndSet(true, false)) { + handler.removeCallbacks(processingLoop); + + for (ChannelStateMachine channel : channelTable.values()) { + try { + if (channel.openResultDispatcher != null) { + channel.openResultDispatcher.onResult( + ChannelStatusCodes.INTERNAL_ERROR, null, channel.channelPath); + } + channel.forceClose(); + } catch (Exception e) { + Log.w(TAG, "Error closing channel on stop", e); + } + } + + channelTable.clear(); + transport.clear(); + taskQueue.clear(); + + Log.d(TAG, "ChannelManager stopped"); + } + } + + public void setChannelCallbacks(ChannelCallbacks callbacks) { + this.channelCallbacks = callbacks; + } + + public ChannelStateMachine getChannel(ChannelToken token) { + return channelTable.get(token); + } + + public void removeChannel(ChannelToken token) { + channelTable.remove(token); + } + + + public boolean hasActiveChannelForNode(String nodeId) { + for (ChannelStateMachine c : channelTable.values()) { + if (c.token.nodeId.equals(nodeId) + && (c.connectionState == ChannelStateMachine.CONNECTION_STATE_ESTABLISHED + || c.connectionState == ChannelStateMachine.CONNECTION_STATE_OPEN_SENT)) + return true; + } + return false; + } + + private void processChannelIO(ChannelStateMachine channel) throws IOException { + if (channel.sendingState == ChannelStateMachine.SENDING_STATE_WAITING_TO_READ) { + channel.processOutgoingData(); + } + + if (channel.receivingState == ChannelStateMachine.RECEIVING_STATE_WAITING_TO_WRITE) { + channel.processIncomingBuffer(); + } + } + + public void openChannel(AppKey appKey, String nodeId, String path, + boolean isReliable, OpenChannelCallback callback) { + openChannel(ChannelAssetApiEnum.ORIGIN_CHANNEL_API, appKey, nodeId, path, isReliable, callback); + } + + public void openChannel(ChannelAssetApiEnum origin, AppKey appKey, String nodeId, + String path, boolean isReliable, OpenChannelCallback callback) { + Log.d(TAG, String.format("openChannel(%s, %s, %s)", appKey.packageName, nodeId, path)); + + if (!isRunning.get()) { + Log.w(TAG, "openChannel called while not running, deferring 500ms"); + handler.postDelayed(() -> { + if (!isRunning.get()) { + Log.e(TAG, "openChannel: still not running after delay, failing"); + callback.onResult(ChannelStatusCodes.INTERNAL_ERROR, null, path); + } else { + openChannel(origin, appKey, nodeId, path, isReliable, callback); + } + }, 500); + return; + } + + if (isInCooldown()) { + long delay = cooldownUntil - System.currentTimeMillis() + 100; + Log.d(TAG, "Deferring channel open by " + delay + "ms due to cooldown"); + handler.postDelayed( + () -> openChannel(origin, appKey, nodeId, path, isReliable, callback), delay); + return; + } + + taskQueue.offer(new ChannelTask(this) { + @Override + protected void execute() throws IOException, ChannelException { + doOpenChannel(origin, appKey, nodeId, path, isReliable, callback); + } + }); + } + + + private void doOpenChannel(ChannelAssetApiEnum origin, AppKey appKey, String nodeId, + String path, boolean isReliable, OpenChannelCallback callback) + throws IOException, ChannelException { + nodeId = wearable.resolveToWearableNodeId(nodeId); + + WearableConnection connection = wearable.getActiveConnections().get(nodeId); + if (connection == null) { +// Log.w(TAG, "Target node not connected: " + nodeId); +// callback.onResult(ChannelStatusCodes.CHANNEL_NOT_CONNECTED, null, path); + scheduleConnectRetry(origin, appKey, nodeId, path, isReliable, callback, 1); + return; + } + doOpenChannelWithConnection(origin, appKey, nodeId, path, isReliable, callback, connection); + } + + private void scheduleConnectRetry(ChannelAssetApiEnum origin, AppKey appKey, String nodeId, + String path, boolean isReliable, + OpenChannelCallback callback, int attempt) { + if (attempt > CONNECT_RETRY_MAX) { + Log.w(TAG, "scheduleConnectRetry: node " + nodeId + " still not connected after " + + CONNECT_RETRY_MAX + " retries"); + callback.onResult(ChannelStatusCodes.CHANNEL_NOT_CONNECTED, null, path); + return; + } + + Log.d(TAG, "scheduleConnectRetry: node " + nodeId + " not connected, retry " + + attempt + "/" + CONNECT_RETRY_MAX + " in " + CONNECT_RETRY_MS + "ms"); + + handler.postDelayed(() -> { + WearableConnection conn = wearable.getActiveConnections().get(nodeId); + if (conn == null) { + scheduleConnectRetry(origin, appKey, nodeId, path, isReliable, callback, attempt + 1); + } else { + try { + doOpenChannelWithConnection(origin, appKey, nodeId, path, isReliable, callback, conn); + } catch (Exception e) { + Log.w(TAG, "scheduleConnectRetry retry failed:" + e.getMessage() ); + callback.onResult(ChannelStatusCodes.INTERNAL_ERROR, null, path); + } + } + }, CONNECT_RETRY_MS); + } + + private void doOpenChannelWithConnection(ChannelAssetApiEnum origin, AppKey appKey, + String nodeId, String path, boolean isReliable, + OpenChannelCallback callback, + WearableConnection connection) + throws IOException, ChannelException { + long channelId = generateChannelId(nodeId); + ChannelToken token = new ChannelToken(nodeId, appKey, channelId, true); + + IBinder.DeathRecipient deathRecipient = () -> onBinderDied(token); + + ChannelCallbacks callbacks = getCallbacksOrNull(origin); + + ChannelStateMachine channel = new ChannelStateMachine( + token, this, transport, callbacks, origin, isReliable, false, deathRecipient, handler); + channel.channelPath = path; + channel.openResultDispatcher = callback; + + channelTable.put(token, channel); + + channel.openTimeoutOp = new PendingOperation(handler, + () -> onOpenTimeout(token), OPEN_TIMEOUT_MS, "Open channel"); + + channel.sendOpenRequest(); + } + + public void onNodeDisconnected(final String nodeId) { + taskQueue.offer(new ChannelTask(this, false) { + @Override + protected void execute() throws IOException, ChannelException { + int cleaned = 0; + for (ChannelStateMachine channel : new ArrayList<>(channelTable.values())) { + if (!channel.token.nodeId.equals(nodeId)) continue; + + setChannel(channel); + + if (channel.openTimeoutOp != null) { + channel.openTimeoutOp.cancel(); + channel.openTimeoutOp = null; + } + + if (channel.sendPendingOp != null) { + channel.sendPendingOp.cancel(); + channel.sendPendingOp = null; + } + + if (channel.openResultDispatcher != null) { + channel.openResultDispatcher.onResult( + ChannelStatusCodes.CHANNEL_NOT_CONNECTED, + null, channel.channelPath + ); + channel.openResultDispatcher = null; + } + + try { + channel.forceClose(); + } catch (Exception e) { + Log.w(TAG, "onNodeDisconnected: force-closing channel for disconnected node"); + } + + channelTable.remove(channel.token); + cleaned++; + } + + if (cleaned > 0) { + Log.d(TAG, "onNodeDisconnected: cleaned " + cleaned + " channel(s) for node " + nodeId); + } + } + }); + } + + private void onOpenTimeout(ChannelToken token) { + taskQueue.offer(new ChannelTask(this) { + @Override + protected void execute() throws IOException, ChannelException { + ChannelStateMachine ch = channelTable.get(token); + if (ch == null) return; + setChannel(ch); + + if (ch.connectionState == ChannelStateMachine.CONNECTION_STATE_ESTABLISHED) return; + + ch.openTimeoutOp = null; + if (ch.openResultDispatcher == null) { + Log.w(TAG, "onOpenTimeout: dispatcher already null for " + token); + } else { + ch.openResultDispatcher.onResult( + ChannelStatusCodes.CHANNEL_NOT_CONNECTED, null, ch.channelPath); + ch.openResultDispatcher = null; + } + + try { + ch.sendCloseRequest(0); + } catch (IOException e) { + Log.w(TAG, "onOpenTimeout: failed to send close to watch: " + e.getMessage()); + } + ch.forceClose(); + channelTable.remove(token); + } + }); + } + + private long generateChannelId(String nodeId) { + for (int i = 0; i < 5; i++) { + long id = random.nextLong() & Long.MAX_VALUE; + if (channelTable.get(nodeId, id, true) == null) return id; + } + throw new IllegalStateException( + "Failed to generate a free channel ID. Table size: " + channelTable.size()); + } + + public void closeChannel(ChannelToken token, int errorCode) { + ChannelStateMachine channel = getChannel(token); + if (channel == null) { + Log.w(TAG, "closeChannel: channel not found"); + return; + } + + taskQueue.offer(new ChannelTask(this) { + @Override + protected void execute() throws IOException { + setChannel(channel); + doCloseChannel(channel, errorCode); + } + }); + } + + private void doCloseChannel(ChannelStateMachine channel, int errorCode) throws IOException { + if (channel==null) { + Log.w(TAG, "doCloseChannel: channel is null, skipping"); + return; + } + + if (channel.connectionState == ChannelStateMachine.CONNECTION_STATE_CLOSED) { + Log.d(TAG, "doCloseChannel: channel already closed, removing from table"); + channelTable.remove(channel.token); + return; + } + + try { + channel.sendCloseRequest(errorCode); + channel.forceClose(); + } finally { + channelTable.remove(channel.token); + } + } + + public void onChannelRequestReceived(WearableConnection connection, String sourceNodeId, Request request) { + Log.d(TAG, String.format("onChannelRequestReceived (%s): %s", sourceNodeId, request.toString())); + if (request.request == null) { + Log.w(TAG, "Received channel request with null ChannelRequest"); + return; + } + + ChannelRequest channelRequest = request.request; + + if (channelRequest.channelControlRequest != null) { + taskQueue.offer(new OnChannelControlTask(this, sourceNodeId, connection, request)); + } else if (channelRequest.channelDataRequest != null) { + taskQueue.offer(new OnChannelDataTask(this, sourceNodeId, channelRequest.channelDataRequest)); + } else if (channelRequest.channelDataAckRequest != null) { + taskQueue.offer(new OnChannelDataAckTask(this, sourceNodeId, channelRequest.channelDataAckRequest)); + } + } + + public void sendOpenRequest(ChannelStateMachine channel) throws IOException { + WearableConnection conn = requireConnection(channel.token.nodeId); + + ChannelControlRequest ctrl = new ChannelControlRequest.Builder() + .type(CHANNEL_CONTROL_TYPE_OPEN) + .channelId(channel.token.channelId) + .fromChannelOperator(channel.token.thisNodeWasOpener) + .packageName(channel.token.appKey.packageName) + .signatureDigest(channel.token.appKey.signatureDigest) + .path(channel.channelPath) + .isReliable(channel.isReliable) + .build(); + + conn.writeMessage(buildRootMessage(channel, ctrl)); + Log.d(TAG, "Sent open request for " + channel.token); + } + + + public void sendOpenAck(ChannelStateMachine channel) throws IOException { + WearableConnection conn = requireConnection(channel.token.nodeId); + + ChannelControlRequest ctrl = new ChannelControlRequest.Builder() + .type(CHANNEL_CONTROL_TYPE_OPEN_ACK) + .channelId(channel.token.channelId) + .fromChannelOperator(channel.token.thisNodeWasOpener) + .packageName(channel.token.appKey.packageName) + .signatureDigest(channel.token.appKey.signatureDigest) + .path(channel.channelPath) + .build(); + + conn.writeMessage(buildRootMessage(channel, ctrl)); + Log.d(TAG, "Sent open ACK for " + channel.token); + } + + public void sendCloseRequest(ChannelStateMachine channel, int errorCode) throws IOException { + WearableConnection conn = wearable.getActiveConnections().get(channel.token.nodeId); + if (conn == null) { + Log.w(TAG, "Cannot send close — connection not found for " + channel.token.nodeId); + return; + } + + ChannelControlRequest ctrl = new ChannelControlRequest.Builder() + .type(CHANNEL_CONTROL_TYPE_CLOSE) + .channelId(channel.token.channelId) + .fromChannelOperator(channel.token.thisNodeWasOpener) + .packageName(channel.token.appKey.packageName) + .signatureDigest(channel.token.appKey.signatureDigest) + .closeErrorCode(errorCode) + .build(); + + conn.writeMessage(buildRootMessage(channel, ctrl)); + Log.d(TAG, "Sent close request for " + channel.token); + } + + public boolean sendData(ChannelStateMachine channel, byte[] data, boolean isFinal, long requestId) { + WearableConnection conn = wearable.getActiveConnections().get(channel.token.nodeId); + if (conn == null) { + Log.w(TAG, "Cannot send data — connection not found"); + return false; + } + + try { + ChannelDataHeader hdr = new ChannelDataHeader.Builder() + .channelId(channel.token.channelId) + .fromChannelOperator(channel.token.thisNodeWasOpener) + .requestId(requestId) + .build(); + + ChannelDataRequest dataReq = new ChannelDataRequest.Builder() + .header(hdr) + .payload(ByteString.of(data)) + .finalMessage(isFinal) + .build(); + + conn.writeMessage(buildDataRootMessage(channel, dataReq)); + return true; + } catch (IOException e) { + Log.e(TAG, "Failed to send channel data", e); + return false; + } + } + + public void sendDataAck(ChannelStateMachine channel, long offset, boolean isFinal) { + WearableConnection conn = wearable.getActiveConnections().get(channel.token.nodeId); + if (conn == null) { + Log.w(TAG, "Cannot send ack — connection not found"); + return; + } + + try { + ChannelDataHeader hdr = new ChannelDataHeader.Builder() + .channelId(channel.token.channelId) + .fromChannelOperator(channel.token.thisNodeWasOpener) + .requestId(offset) + .build(); + + ChannelDataAckRequest ack = new ChannelDataAckRequest.Builder() + .header(hdr) + .finalMessage(isFinal) + .build(); + + conn.writeMessage(buildAckRootMessage(channel, ack)); + } catch (IOException e) { + Log.e(TAG, "Failed to send data ack", e); + } + } + + private WearableConnection requireConnection(String nodeId) throws IOException { + WearableConnection conn = wearable.getActiveConnections().get(nodeId); + if (conn == null) throw new IOException("No connection to " + nodeId); + return conn; + } + + private RootMessage buildRootMessage(ChannelStateMachine ch, ChannelControlRequest ctrl) { + ChannelRequest cr = new ChannelRequest.Builder() + .channelControlRequest(ctrl) + .version(CHANNEL_REQ_VERSION) + .origin(CHANNEL_ORIGIN_CHANNEL_API) + .build(); + + Request req = new Request.Builder() + .requestId(requestIdCounter.getAndIncrement()) + .targetNodeId(ch.token.nodeId) + .sourceNodeId(localNodeId) + .packageName(ch.token.appKey.packageName) + .signatureDigest(ch.token.appKey.signatureDigest) + .path(ch.channelPath) + .request(cr) + .unknown5(0) + // .generation(generationCounter.get()) + .requiresResponse(true) + .build(); + + return new RootMessage.Builder().channelRequest(req).build(); + } + + private RootMessage buildDataRootMessage(ChannelStateMachine ch, ChannelDataRequest dataReq) { + ChannelRequest cr = new ChannelRequest.Builder() + .channelDataRequest(dataReq) + .version(CHANNEL_REQ_VERSION) + .origin(CHANNEL_ORIGIN_CHANNEL_API) + .build(); + Request req = new Request.Builder() + .requestId(requestIdCounter.getAndIncrement()) + .targetNodeId(ch.token.nodeId) + .sourceNodeId(localNodeId) + .packageName(ch.token.appKey.packageName) + .signatureDigest(ch.token.appKey.signatureDigest) + .unknown5(0) + .path("") + .request(cr) + .generation(generationCounter.get()) + .build(); + return new RootMessage.Builder().channelRequest(req).build(); + } + + private RootMessage buildAckRootMessage(ChannelStateMachine ch, ChannelDataAckRequest ack) { + ChannelRequest cr = new ChannelRequest.Builder() + .channelDataAckRequest(ack) + .version(CHANNEL_REQ_VERSION) + .origin(CHANNEL_ORIGIN_CHANNEL_API) + .build(); + Request req = new Request.Builder() + .requestId(requestIdCounter.getAndIncrement()) + .targetNodeId(ch.token.nodeId) + .sourceNodeId(localNodeId) + .packageName(ch.token.appKey.packageName) + .signatureDigest(ch.token.appKey.signatureDigest) + .unknown5(0) + .path("") + .request(cr) + .generation(generationCounter.get()) + .build(); + return new RootMessage.Builder().channelRequest(req).build(); + } + + private void onBinderDied(ChannelToken token) { + Log.w(TAG, "Client died for channel: " + token); + closeChannel(token, ChannelStatusCodes.CLOSE_REASON_LOCAL_CLOSE); + } + + public static void sendFileResult(IWearableCallbacks callbacks, int statusCode) { + try { + callbacks.onChannelSendFileResponse(new ChannelSendFileResponse(statusCode)); + } catch (RemoteException e) { + Log.w(TAG, "Failed to send sendFile result", e); + } + } + + public static void receiveFileResult(IWearableCallbacks callbacks, int statusCode) { + try { + callbacks.onChannelReceiveFileResponse(new ChannelReceiveFileResponse(statusCode)); + } catch (RemoteException e) { + Log.w(TAG, "Failed to send receiveFile result", e); + } + } + + public static void getInputStreamError(IWearableCallbacks callbacks, int statusCode) { + try { + callbacks.onGetChannelInputStreamResponse(new GetChannelInputStreamResponse(statusCode, null)); + } catch (RemoteException e) { + Log.w(TAG, "Failed to send getInputStream error", e); + } + } + + public static void getOutputStreamError(IWearableCallbacks callbacks, int statusCode) { + try { + callbacks.onGetChannelOutputStreamResponse(new GetChannelOutputStreamResponse(statusCode, null)); + } catch (RemoteException e) { + Log.w(TAG, "Failed to send getOutputStream error", e); + } + } + + public void sendCloseForAllChannels(String nodeId) { + int sent = 0; + for (ChannelStateMachine channel : new ArrayList<>(channelTable.values())) { + if (!channel.token.nodeId.equals(nodeId)) + continue; + + if (channel.connectionState == ChannelStateMachine.CONNECTION_STATE_CLOSED) + continue; + + try { + sendCloseRequest(channel, 0); + sent++; + } catch (IOException e) { + Log.w(TAG, "sendCloseForAllChannels: failed for " + channel.token + ": " + e.getMessage()); + } + } + } + + public ChannelCallbacks getChannelCallbacks() { + return channelCallbacks; + } + + public ChannelTransport getTransport() { + return transport; + } + + public Handler getHandler() { + return handler; + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelStateMachine.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelStateMachine.java new file mode 100644 index 0000000000..82a8d46596 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelStateMachine.java @@ -0,0 +1,833 @@ +package org.microg.gms.wearable.channel; + +import android.os.Handler; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.android.gms.wearable.internal.IChannelStreamCallbacks; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ChannelStateMachine { + private static final String TAG = "ChannelStateMachine"; + + private static final int DEFAULT_CHUNK_SIZE = 8192; + private static final long DATA_ACK_TIMEOUT_MS = 2000; + private static final int MAX_CHUNK_SIZE = 65536; + private static final int MAX_PENDING_CHUNKS = 64; + + public static final int CONNECTION_STATE_NOT_STARTED = 0; + public static final int CONNECTION_STATE_OPEN_SENT = 1; + public static final int CONNECTION_STATE_ESTABLISHED = 2; + public static final int CONNECTION_STATE_CLOSING = 3; + public static final int CONNECTION_STATE_CLOSED = 4; + + public static final int SENDING_STATE_NOT_STARTED = 5; + public static final int SENDING_STATE_WAITING_TO_READ = 6; + public static final int SENDING_STATE_WAITING_FOR_ACK = 7; + public static final int SENDING_STATE_CLOSED = 8; + + public static final int RECEIVING_STATE_WAITING_FOR_DATA = 9; + public static final int RECEIVING_STATE_WAITING_TO_WRITE = 10; + public static final int RECEIVING_STATE_CLOSED = 11; + + public final ChannelToken token; + public final boolean isLocalOpener; + public final ChannelTransport transport; + + private final ChannelManager channelManager; + private final ChannelCallbacks callbacks; + private final IBinder.DeathRecipient deathRecipient; + private final Handler handler; + + public int connectionState = CONNECTION_STATE_NOT_STARTED; + public int sendingState = SENDING_STATE_NOT_STARTED; + public int receivingState = RECEIVING_STATE_WAITING_FOR_DATA; + + public String channelPath; + + public long lastAckedOffset = 0; + public long sequenceNumber = 0; + + public ParcelFileDescriptor inputFd; + public IChannelStreamCallbacks inputCallbacks; + private final Deque pendingChunks = new ArrayDeque<>(); + + public ParcelFileDescriptor outputFd; + public IChannelStreamCallbacks outputCallbacks; + public ByteBuffer sendBuffer; + private long sendOffset; + long sendMaxLength; + + public PendingOperation openTimeoutOp; + public PendingOperation sendPendingOp; + + public int closeReason; + + public OpenChannelCallback openResultDispatcher; + + private final AtomicBoolean sendInProgress = new AtomicBoolean(false); + private final Object stateLock = new Object(); + + public final long creationTime; + + public long totalDataSize = -1; + public long currentSendOffset = 0; + public long totalBytesSent = 0; + public long totalBytesReceived = 0; + + public final ChannelAssetApiEnum apiOrigin; + + public final boolean isReliable; + + private static final class PendingChunk { + final byte[] data; + final long reqId; + final boolean isFinal; + int written; + + PendingChunk(byte[] data, long reqId, boolean isFinal) { + this.data = data; + this.reqId = reqId; + this.isFinal = isFinal; + } + } + + public ChannelStateMachine( + ChannelToken token, + ChannelManager channelManager, + ChannelTransport transport, + ChannelCallbacks callbacks, + ChannelAssetApiEnum apiOrigin, + boolean isReliable, + boolean isLocalOpener, + IBinder.DeathRecipient deathRecipient, + Handler handler) { + + this.token = token; + this.channelManager = channelManager; + this.transport = transport; + this.callbacks = callbacks; + this.isLocalOpener = isLocalOpener; + this.deathRecipient = deathRecipient; + this.handler = handler; + this.apiOrigin = Objects.requireNonNull(apiOrigin, "apiOrigin"); + this.creationTime = System.currentTimeMillis(); + this.isReliable = isReliable; + } + + private ChannelCallbacks resolveCallbacks() { + if (callbacks != null) return callbacks; + return channelManager.getCallbacksOrNull(apiOrigin); + } + + public boolean hasInputStream() { + return inputFd != null; + } + + public boolean hasOutputStream() { + return outputFd != null; + } + + private boolean isValidConnectionStateTransition(int from, int to) { + switch (from) { + case CONNECTION_STATE_NOT_STARTED: + return to == CONNECTION_STATE_OPEN_SENT || to == CONNECTION_STATE_ESTABLISHED; + case CONNECTION_STATE_OPEN_SENT: + return to == CONNECTION_STATE_ESTABLISHED || to == CONNECTION_STATE_CLOSED; + case CONNECTION_STATE_ESTABLISHED: + return to == CONNECTION_STATE_CLOSING || to == CONNECTION_STATE_CLOSED; + case CONNECTION_STATE_CLOSING: + return to == CONNECTION_STATE_CLOSED; + default: + return false; + } + } + + public void setConnectionState(int newState) { + synchronized (stateLock) { + if (connectionState == newState) { + Log.v(TAG, String.format("Channel(%s): Already in state %s", + token, getConnectionStateString(newState))); + return; + } + + if (newState == CONNECTION_STATE_CLOSED) { + this.connectionState = newState; + return; + } + + if (!isValidConnectionStateTransition(connectionState, newState)) { + Log.w(TAG, String.format("Channel(%s): Unexpected state transition %s -> %s (allowing)", + token, getConnectionStateString(connectionState), + getConnectionStateString(newState))); + } else { + Log.v(TAG, String.format("Channel(%s): %s -> %s", + token, getConnectionStateString(connectionState), + getConnectionStateString(newState))); + } + + this.connectionState = newState; + } + } + + public void setSendingState(int newState) { + Log.v(TAG, String.format("Channel(%s): Sender %s -> %s", + token, getSendingStateString(sendingState), + getSendingStateString(newState))); + this.sendingState = newState; + } + + public void setReceivingState(int newState) { + Log.v(TAG, String.format("Channel(%s): Receiver %s -> %s", + token, getReceivingStateString(receivingState), + getReceivingStateString(newState))); + this.receivingState = newState; + } + + public void sendOpenRequest() throws IOException { + synchronized (stateLock) { + + if (connectionState != CONNECTION_STATE_NOT_STARTED) { + throw new IllegalStateException("Cannot send OPEN from state: " + + getConnectionStateString(connectionState)); + } + + Log.d(TAG, "Sending open request for channel: " + token); + setConnectionState(CONNECTION_STATE_OPEN_SENT); + channelManager.sendOpenRequest(this); + } + } + + public void sendOpenAck() throws IOException { + synchronized (stateLock) { + if (connectionState != CONNECTION_STATE_ESTABLISHED && + connectionState != CONNECTION_STATE_OPEN_SENT) { + Log.w(TAG, "Sending ACK from unexpected state: " + + getConnectionStateString(connectionState)); + } + + Log.d(TAG, "Sending open ACK for channel: " + token); + channelManager.sendOpenAck(this); + } + } + + public void onChannelOpenAckReceived() throws ChannelException { + synchronized (stateLock) { + if (connectionState != CONNECTION_STATE_OPEN_SENT) { + Log.w(TAG, "Received OPEN_ACK in wrong state: " + connectionState); + throw new ChannelException(token, "Received OPEN_ACK in wrong state"); + } + + if (openResultDispatcher == null) { + Log.w(TAG, "Bad state: CONNECTION_STATE_OPEN_SENT but no callbacks"); + throw new ChannelException(token, "No open callback set"); + } + + if (openTimeoutOp == null) { + Log.w(TAG, "Bad state: CONNECTION_STATE_OPEN_SENT but no timeout operation"); + throw new ChannelException(token, "No timeout operation"); + } + + if (!openTimeoutOp.cancel()) { + Log.i(TAG, "Received OPEN_ACK but request already timed out"); + return; + } + + openTimeoutOp = null; + try { + openResultDispatcher.onResult(ChannelStatusCodes.SUCCESS, token, channelPath); + } catch (Exception e) { + Log.e(TAG, "Error in open result callback", e); + } finally { + openResultDispatcher = null; + } + + setConnectionState(CONNECTION_STATE_ESTABLISHED); + } + } + + public void processOutgoingData() throws IOException { + if (sendingState != SENDING_STATE_WAITING_TO_READ) { + return; + } + + if (outputFd == null) { + Log.e(TAG, "SENDING_STATE_WAITING_TO_READ but no output FD"); + return; + } + + if (!sendInProgress.compareAndSet(false, true)) { + return; + } + + try { + if (sendBuffer == null) { + sendBuffer = ByteBuffer.allocate(DEFAULT_CHUNK_SIZE); + Log.d(TAG, "Created send buffer of size " + DEFAULT_CHUNK_SIZE); + } + + if (sendOffset > 0) { + skipBytes(sendOffset); + sendOffset = 0; + } + + sendBuffer.clear(); + + if (sendMaxLength >= 0 && sendMaxLength < sendBuffer.capacity()) { + sendBuffer.limit((int) sendMaxLength); + } + + int lastRead = 0; + boolean gotAnyData = false; + boolean hitEof = false; + while (sendBuffer.hasRemaining()) + { + lastRead = transport.read(outputFd, sendBuffer.array(), + sendBuffer.position(), sendBuffer.remaining()); + if (lastRead == 0) break; + if (lastRead < 0) { + hitEof = true; + break; + } + sendBuffer.position(sendBuffer.position() + lastRead); + gotAnyData = true; + } + + int bytesRead = sendBuffer.hasRemaining() ? transport.read(outputFd, + sendBuffer.array(), sendBuffer.position(), 0) : 0; + + if (!gotAnyData && !hitEof) + { + sendBuffer.clear(); + return; + } + + sendBuffer.flip(); + + if (sendMaxLength >= 0) + sendMaxLength -= sendBuffer.remaining(); + + byte[] data = new byte[sendBuffer.remaining()]; + sendBuffer.get(data); + + boolean isFinal = hitEof || (sendMaxLength == 0); + + if (data.length == 0 && !isFinal) + return; + + long requestId = sequenceNumber++; + + Log.d(TAG, String.format("Sending chunk: size=%d, isFinal=%b, seq=%d", + data.length, isFinal, requestId)); + + if (channelManager.sendData(this, data, isFinal, requestId)) { + setSendingState(SENDING_STATE_WAITING_FOR_ACK); + + sendPendingOp = new PendingOperation(handler, + this::onAckTimeout, DATA_ACK_TIMEOUT_MS, "Send data chunk"); + } else { + Log.e(TAG, "Failed to send data chunk"); + onChannelOutputClosed(ChannelStatusCodes.INTERNAL_ERROR, 0); + } + currentSendOffset += data.length; + totalBytesSent += data.length; + + Log.d(TAG, String.format("Sent chunk: %d bytes, offset=%d/%d (%.1f%%)", + data.length, + currentSendOffset, + totalDataSize, + totalDataSize > 0 ? (currentSendOffset * 100.0 / totalDataSize) : 0)); + + } finally { + sendInProgress.set(false); + } + + } + + private void skipBytes(long skip) throws IOException { + byte[] temp = new byte[8192]; + long remaining = skip; + + while (remaining > 0) { + int toRead = (int) Math.min(temp.length, remaining); + int read = transport.read(outputFd, temp, 0, toRead); + if (read < 0) { + throw new IOException("EOF while skipping bytes"); + } + remaining -= read; + } + } + + private void onAckTimeout() { + Log.d(TAG, "No DATA_ACK within " + DATA_ACK_TIMEOUT_MS + " ms," + + "assuming delivered, resuming writes"); + sendPendingOp = null; + + if (sendingState == SENDING_STATE_WAITING_FOR_ACK) { + setSendingState(SENDING_STATE_WAITING_TO_READ); + } + } + + public void onDataAckReceived(long ackOffset, boolean isFinal) { + if (sendingState != SENDING_STATE_WAITING_FOR_ACK) { + Log.w(TAG, "Received ACK but not waiting for it"); + return; + } + + if (sendPendingOp != null) { + sendPendingOp.cancel(); + sendPendingOp = null; + } + +// lastAckedOffset = ackOffset; + + if (isFinal) { + if (connectionState == CONNECTION_STATE_CLOSING) { + Log.d(TAG, "onDataAckReceived: Received final ACK during channel close — closing output"); + try { + onChannelOutputClosed(ChannelStatusCodes.CLOSE_REASON_REMOTE_CLOSE, closeReason); + } catch (IOException e) { + Log.e(TAG, "Error closing output during channel close", e); + } + } else { + Log.d(TAG, "onDataAckReceived: Remote node closed input stream before all data was sent"); + try { + onChannelOutputClosed(ChannelStatusCodes.CLOSE_REASON_NORMAL, 0); + } catch (IOException e) { + Log.e(TAG, "Error closing output after remote input close", e); + } + } + } else { + setSendingState(SENDING_STATE_WAITING_TO_READ); + } + } + + public void onDataReceived(byte[] data, boolean isFinal, long requestId) throws ChannelException { + synchronized (stateLock) { + if (connectionState != CONNECTION_STATE_ESTABLISHED && + connectionState != CONNECTION_STATE_CLOSING) { + Log.w(TAG, "onDataReceived: bad connection state " + + getConnectionStateString(connectionState) + " — dropping"); + return; + } + } + + if (receivingState == RECEIVING_STATE_CLOSED) { + Log.w(TAG, "onDataReceived: receiver closed — dropping requestId=" + requestId); + return; + } + + if (data.length > MAX_CHUNK_SIZE) { + Log.w(TAG, "onDataReceived: payload " + data.length + " > MAX_CHUNK_SIZE — dropping"); + return; + } + + synchronized (pendingChunks) { + for (PendingChunk pc : pendingChunks) { + if (pc.reqId == requestId) { + Log.d(TAG, "onDataReceived: duplicate requestId=" + requestId + " — ignoring"); + return; + } + } + + if (pendingChunks.size() >= MAX_PENDING_CHUNKS) { + Log.w(TAG, "onDataReceived: onDataReceived: queue full (" + + MAX_PENDING_CHUNKS + ") dropping requestId=" + requestId); + return; + } + + pendingChunks.addLast(new PendingChunk(data, requestId, isFinal)); + Log.d(TAG, "onDataReceived: queued requestId=" + requestId + " size=" + data.length + + " isFinal=" + isFinal + " queueDepth=" + pendingChunks.size()); + } + + if (inputFd != null) + transport.setMode(inputFd, ChannelTransport.IOMode.WRITE); + + setReceivingState(RECEIVING_STATE_WAITING_TO_WRITE); + } + + public void processIncomingBuffer() throws IOException { + if (receivingState != RECEIVING_STATE_WAITING_TO_WRITE) { + return; + } + + if (inputFd == null) { + return; + } + + boolean appClosedInput = false; + long lastDrainedRequestId = -1; + boolean lastDrainedFinal = false; + + while (true) { + PendingChunk chunk; + synchronized (pendingChunks) { + chunk = pendingChunks.peekFirst(); + } + + if (chunk == null) break; + + int remaining = chunk.data.length - chunk.written; + + int written = 0; + if (remaining > 0) + written = transport.write(inputFd, chunk.data, chunk.written, remaining); + + if (written == -1) { + Log.d(TAG, "processIncomingBuffer: app closed input for " + token); + appClosedInput = true; + break; + } + + if (written > 0) + chunk.written += written; + + if (chunk.written < chunk.data.length) { + Log.d(TAG, "processIncomingBuffer: pipe full, " + (chunk.data.length - chunk.written) + + " bytes left on requestId=" + chunk.reqId); + break; + } + + synchronized (pendingChunks) { + pendingChunks.pollFirst(); + } + + lastAckedOffset = chunk.reqId + 1; + lastDrainedRequestId = chunk.reqId; + lastDrainedFinal = chunk.isFinal; + channelManager.sendDataAck(this, chunk.reqId, chunk.isFinal); + Log.d(TAG, "processIncomingBuffer: drained+ack requestId=" + + chunk.reqId + " isFinal=" + chunk.isFinal); + } + + if (appClosedInput) { + long ackId = lastDrainedRequestId >= 0 + ? lastDrainedRequestId : Math.max(0, lastAckedOffset - 1); + channelManager.sendDataAck(this, ackId, true); + synchronized (pendingChunks) { + pendingChunks.clear(); + } + onChannelInputClosed(ChannelStatusCodes.CLOSE_REASON_NORMAL, 0); + return; + } + + boolean queueEmpty; + synchronized (pendingChunks) { + queueEmpty = pendingChunks.isEmpty(); + } + if (queueEmpty) { + transport.setMode(inputFd, ChannelTransport.IOMode.NONE); + } + } + + public boolean hasPendingIncoming() { + synchronized (pendingChunks) { + return !pendingChunks.isEmpty(); + } + } + + public void sendCloseRequest(int errorCode) throws IOException { + Log.d(TAG, "Sending close request: errorCode=" + errorCode); + channelManager.sendCloseRequest(this, errorCode); + } + + public void onRemoteCloseReceived(int errorCode) throws IOException { + Log.d(TAG, "Received remote close: errorCode=" + errorCode); + + if (openResultDispatcher != null) { + try { + openResultDispatcher.onResult(ChannelStatusCodes.CHANNEL_CLOSED, null, channelPath); + } catch (Exception e) { + Log.e(TAG, "Error in open result callback", e); + } finally { + openResultDispatcher = null; + } + } + + if (outputCallbacks != null) { + try { + outputCallbacks.onChannelClosed(ChannelStatusCodes.CLOSE_REASON_REMOTE_CLOSE, errorCode); + } catch (RemoteException e) { + Log.w(TAG, "Failed to notify output callbacks", e); + } + } + + onChannelInputClosed(ChannelStatusCodes.CLOSE_REASON_REMOTE_CLOSE, errorCode); + + if (sendingState == SENDING_STATE_CLOSED || sendingState == SENDING_STATE_NOT_STARTED) { + sendCloseRequest(errorCode); + setConnectionState(CONNECTION_STATE_CLOSED); + channelManager.removeChannel(token); + } else { + setConnectionState(CONNECTION_STATE_CLOSING); + closeReason = errorCode; + + if (sendingState == SENDING_STATE_WAITING_TO_READ) { + processOutgoingData(); + } + } + } + + public void abortOpenChannel() throws IOException, ChannelException { + if (openResultDispatcher != null) { + Log.w(TAG, "openChannel cancelled - remote node not reachable"); + openResultDispatcher.onResult(ChannelStatusCodes.CHANNEL_NOT_CONNECTED, null, channelPath); + openResultDispatcher = null; + } + + onChannelOutputClosed(ChannelStatusCodes.CLOSE_REASON_LOCAL_CLOSE, 0); + onChannelInputClosed(ChannelStatusCodes.CLOSE_REASON_LOCAL_CLOSE, 0); + throw new ChannelException(token, "Open channel aborted"); + } + + public void setInputStream(ParcelFileDescriptor fd, IChannelStreamCallbacks callbacks) + throws RemoteException { + if (receivingState == RECEIVING_STATE_CLOSED) { + throw new IllegalStateException("Cannot set input FD after closing"); + } + if (inputFd != null) { + throw new IllegalStateException("Input FD already set"); + } + if (fd == null) { + throw new NullPointerException("fd is null"); + } + + this.inputFd = fd; + this.inputCallbacks = callbacks; + + transport.register(fd); + linkToDeath(callbacks); + + Log.d(TAG, "Input stream configured for channel " + token); + } + + public void setOutputStream(ParcelFileDescriptor fd, IChannelStreamCallbacks callbacks, + long startOffset, long length) throws RemoteException { + if (startOffset < 0) { + throw new IllegalArgumentException("invalid startOffset " + startOffset); + } + if (length != -1 && length < 0) { + throw new IllegalArgumentException("invalid length " + length); + } + if (sendingState != SENDING_STATE_NOT_STARTED) { + throw new IllegalStateException("Output FD already set"); + } + if (outputFd != null) { + throw new IllegalStateException("Output FD already set"); + } + if (fd == null) { + throw new NullPointerException("fd is null"); + } + + this.outputFd = fd; + this.outputCallbacks = callbacks; + this.sendOffset = startOffset; + this.sendMaxLength = length; + this.currentSendOffset = startOffset; + if (length >= 0) { + this.totalDataSize = length; + } else { + try { + long fileSize = fd.getStatSize(); + this.totalDataSize = fileSize - startOffset; + } catch (Exception e) { + this.totalDataSize = -1; + } + } + + transport.register(fd); + transport.setMode(fd, ChannelTransport.IOMode.READ); + + setSendingState(SENDING_STATE_WAITING_TO_READ); + linkToDeath(callbacks); + + Log.d(TAG, String.format("Output stream configured: offset=%d, length=%d", + startOffset, length)); + } + + public void forceClose() throws IOException { + int prevState; + synchronized (stateLock) { + if (connectionState == CONNECTION_STATE_CLOSED) return; + prevState = connectionState; + this.connectionState = CONNECTION_STATE_CLOSED; + } + + if (prevState == CONNECTION_STATE_ESTABLISHED + || prevState == CONNECTION_STATE_OPEN_SENT + || prevState == CONNECTION_STATE_CLOSING) { + try { + channelManager.sendCloseRequest(this, ChannelStatusCodes.CLOSE_REASON_LOCAL_CLOSE); + } catch (IOException e) { + Log.w(TAG, "forceClose: peer notify failed: " + e.getMessage()); + } + } + + if (openTimeoutOp != null) { + openTimeoutOp.cancel(); + openTimeoutOp = null; + } + if (sendPendingOp != null) { + sendPendingOp.cancel(); + sendPendingOp = null; + } + if (openResultDispatcher != null) { + try { + openResultDispatcher.onResult(ChannelStatusCodes.CHANNEL_CLOSED, null, channelPath); + } catch (Exception e) { + Log.e(TAG, "Error in open result callback during forceClose", e); + } finally { + openResultDispatcher = null; + } + } + + synchronized (pendingChunks) { + pendingChunks.clear(); + } + + try { + onChannelInputClosed(ChannelStatusCodes.CLOSE_REASON_LOCAL_CLOSE, 0); + } catch (Exception e) { + Log.w(TAG, "Error closing input during forceClose", e); + } + onChannelOutputClosed(ChannelStatusCodes.CLOSE_REASON_LOCAL_CLOSE, 0); + sendBuffer = null; + } + + public void onChannelInputClosed(int closeReason, int errorCode) throws IOException { + if (inputFd == null) return; + + if (inputCallbacks != null) { + unlinkToDeath(inputCallbacks.asBinder()); + if (closeReason != ChannelStatusCodes.CLOSE_REASON_NORMAL || errorCode != 0) { + try { + inputCallbacks.onChannelClosed(closeReason, errorCode); + } catch (RemoteException e) { + Log.w(TAG, "Failed to notify InputStream of close", e); + } + } + } + + transport.unregister(inputFd); + + try { + inputFd.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close receiving FD", e); + } + + inputFd = null; + inputCallbacks = null; + setReceivingState(RECEIVING_STATE_CLOSED); + + ChannelCallbacks cb = resolveCallbacks(); + if (cb != null) { + try { + cb.onChannelInputClosed(token, channelPath, closeReason, errorCode); + } catch (Exception e) { + Log.e(TAG, "Error in input closed callback", e); + } + } + } + + public void onChannelOutputClosed(int closeReason, int errorCode) throws IOException { + if (outputFd == null) return; + + if (outputCallbacks != null) { + unlinkToDeath(outputCallbacks.asBinder()); + if (closeReason != ChannelStatusCodes.CLOSE_REASON_NORMAL) { + try { + outputCallbacks.onChannelClosed(closeReason, errorCode); + } catch (RemoteException e) { + Log.w(TAG, "Failed to notify OutputStream of close", e); + } + } + } + + transport.unregister(outputFd); + + try { + outputFd.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close sending FD", e); + } + + outputFd = null; + outputCallbacks = null; + sendBuffer = null; + setSendingState(SENDING_STATE_CLOSED); + + ChannelCallbacks cb = resolveCallbacks(); + if (cb != null) { + cb.onChannelOutputClosed(token, channelPath, closeReason, errorCode); + } + } + + private void linkToDeath(IChannelStreamCallbacks callbacks) throws RemoteException { + if (callbacks == null || deathRecipient == null) return; + try { + callbacks.asBinder().linkToDeath(deathRecipient, 0); + } catch (RemoteException e) { + deathRecipient.binderDied(); + } + } + + private void unlinkToDeath(IBinder binder) { + if (binder == null || deathRecipient == null) return; + try { + binder.unlinkToDeath(deathRecipient, 0); + } catch (Exception ignored) {} + } + + public static String getConnectionStateString(int state) { + switch (state) { + case CONNECTION_STATE_NOT_STARTED: return "NOT_STARTED"; + case CONNECTION_STATE_OPEN_SENT: return "OPEN_SENT"; + case CONNECTION_STATE_ESTABLISHED: return "ESTABLISHED"; + case CONNECTION_STATE_CLOSING: return "CLOSING"; + case CONNECTION_STATE_CLOSED: return "CLOSED"; + default: return "UNKNOWN(" + state + ")"; + } + } + + public static String getSendingStateString(int state) { + switch (state) { + case SENDING_STATE_NOT_STARTED: return "NOT_STARTED"; + case SENDING_STATE_WAITING_TO_READ: return "WAITING_TO_READ"; + case SENDING_STATE_WAITING_FOR_ACK: return "WAITING_FOR_ACK"; + case SENDING_STATE_CLOSED: return "CLOSED"; + default: return "UNKNOWN(" + state + ")"; + } + } + + public static String getReceivingStateString(int state) { + switch (state) { + case RECEIVING_STATE_WAITING_FOR_DATA: return "WAITING_FOR_DATA"; + case RECEIVING_STATE_WAITING_TO_WRITE: return "WAITING_TO_WRITE"; + case RECEIVING_STATE_CLOSED: return "CLOSED"; + default: return "UNKNOWN(" + state + ")"; + } + } + + @NonNull + @Override + public String toString() { + return "ChannelStateMachine{token=" + token + + ", path='" + channelPath + "'" + + ", connection=" + getConnectionStateString(connectionState) + + ", sending=" + getSendingStateString(sendingState) + + ", receiving=" + getReceivingStateString(receivingState) + "}"+ + ", age=" + (System.currentTimeMillis() - creationTime) + "ms}"; + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelStatusCodes.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelStatusCodes.java new file mode 100644 index 0000000000..00de5820f9 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelStatusCodes.java @@ -0,0 +1,37 @@ +package org.microg.gms.wearable.channel; + +public class ChannelStatusCodes { + public static final int SUCCESS = 0; + public static final int CLOSE_REASON_NORMAL = 0; + public static final int CLOSE_REASON_LOCAL_CLOSE = 1; + public static final int CLOSE_REASON_REMOTE_CLOSE = 3; + public static final int INTERNAL_ERROR = 8; + public static final int CHANNEL_NOT_CONNECTED = 13; + public static final int CHANNEL_CLOSED = 16; + + // Custom codes + public static final int INVALID_ARGUMENT = 10003; + public static final int CHANNEL_NOT_FOUND = 10004; + public static final int ALREADY_IN_PROGRESS = 10005; + public static final int CHANNEL_LIMIT_REACHED = 10006; + public static final int INVALID_PACKAGE = 10007; + public static final int INVALID_PATH = 10008; + + public static String getStatusName(int status) { + switch (status) { + case SUCCESS: return "SUCCESS"; + case CLOSE_REASON_LOCAL_CLOSE: return "LOCAL_CLOSE"; + case CLOSE_REASON_REMOTE_CLOSE: return "REMOTE_CLOSE"; + case INTERNAL_ERROR: return "INTERNAL_ERROR"; + case CHANNEL_NOT_CONNECTED: return "NOT_CONNECTED"; + case CHANNEL_CLOSED: return "CLOSED"; + case INVALID_ARGUMENT: return "INVALID_ARGUMENT"; + case CHANNEL_NOT_FOUND: return "NOT_FOUND"; + case ALREADY_IN_PROGRESS: return "ALREADY_IN_PROGRESS"; + case CHANNEL_LIMIT_REACHED: return "LIMIT_REACHED"; + case INVALID_PACKAGE: return "INVALID_PACKAGE"; + case INVALID_PATH: return "INVALID_PATH"; + default: return "UNKNOWN(" + status + ")"; + } + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTable.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTable.java new file mode 100644 index 0000000000..2c22fa8197 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTable.java @@ -0,0 +1,73 @@ +package org.microg.gms.wearable.channel; + +import android.util.Log; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class ChannelTable { + private static final String TAG = "ChannelTable"; + + private final Map channels = new ConcurrentHashMap<>(); + + public ChannelStateMachine get(String tokenString) { + return channels.get(tokenString); + } + + public ChannelStateMachine get(ChannelToken token) { + return channels.get(token.toTokenString()); + } + + public ChannelStateMachine get(String nodeId, long channelId, boolean isOpener) { + for (ChannelStateMachine channel : channels.values()) { + ChannelToken token = channel.token; + if (token.nodeId.equals(nodeId) && + token.channelId == channelId && + token.thisNodeWasOpener == isOpener) { + return channel; + } + } + return null; + } + + public ChannelStateMachine getByNodeAndId(String nodeId, long channelId) { + for (ChannelStateMachine channel : channels.values()) { + ChannelToken token = channel.token; + if (token.nodeId.equals(nodeId) && token.channelId == channelId) { + return channel; + } + } + return null; + } + + + public void put(ChannelToken token, ChannelStateMachine channel) { + String key = token.toTokenString(); + channels.put(key, channel); + Log.d(TAG, "Added channel to table: " + token + " (total: " + channels.size() + ")"); + } + + public ChannelStateMachine remove(ChannelToken token) { + String key = token.toTokenString(); + ChannelStateMachine removed = channels.remove(key); + if (removed != null) { + Log.d(TAG, "Removed channel from table: " + token + " (remaining: " + channels.size() + ")"); + } + return removed; + } + + public Collection values() { + return channels.values(); + } + + public void clear() { + int count = channels.size(); + channels.clear(); + Log.d(TAG, "Cleared " + count + " channels from table"); + } + + public int size() { + return channels.size(); + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTask.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTask.java new file mode 100644 index 0000000000..035c73c25e --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTask.java @@ -0,0 +1,70 @@ +package org.microg.gms.wearable.channel; + +import android.util.Log; + +import java.io.IOException; + +public abstract class ChannelTask implements Runnable { + private static final String TAG = "ChannelTask"; + + protected final ChannelManager channelManager; + protected final boolean requiresRunning; + protected ChannelStateMachine channel; + + protected ChannelTask(ChannelManager channelManager) { + this(channelManager, true); + } + + protected ChannelTask(ChannelManager channelManager, boolean requiresRunning) { + this.channelManager = channelManager; + this.requiresRunning = requiresRunning; + } + + protected abstract void execute() throws IOException, ChannelException; + + @Override + public final void run() { + if (requiresRunning && !channelManager.isRunning()) { + Log.d(TAG, "Skipping task - manager not running"); + return; + } + + try { + execute(); + } catch (ChannelException e) { + Log.w(TAG, "Channel exception in task", e); + if (channel != null) { + try { + channel.forceClose(); + channelManager.removeChannel(channel.token); + } catch (Exception ex) { + Log.e(TAG, "Error during cleanup", ex); + } + } + } catch (IOException e) { + Log.w(TAG, "IO exception in task", e); + if (channel != null) { + try { + channel.forceClose(); + channelManager.removeChannel(channel.token); + } catch (Exception ex) { + Log.e(TAG, "Error during cleanup", ex); + } + } + } catch (RuntimeException e) { + Log.e(TAG, "Uncaught runtime exception in task", e); + if (channel != null) { + try { + channel.forceClose(); + channelManager.removeChannel(channel.token); + } catch (Exception ex) { + Log.e(TAG, "Error during cleanup", ex); + } + } + } + } + + protected void setChannel(ChannelStateMachine channel) { + this.channel = channel; + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelToken.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelToken.java new file mode 100644 index 0000000000..d6331514ae --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelToken.java @@ -0,0 +1,152 @@ +package org.microg.gms.wearable.channel; + +import android.util.Base64; + +import com.google.android.gms.wearable.internal.ChannelParcelable; + +import org.microg.gms.wearable.proto.AppKey; + +public final class ChannelToken { + private static final String TAG = "ChannelToken"; + private static final String TOKEN_PREFIX = "chl-"; + + public final String nodeId; + public final AppKey appKey; + public final long channelId; + public final boolean thisNodeWasOpener; + + public ChannelToken(String nodeId, AppKey appKey, long channelId, + boolean thisNodeWasOpener) { + if (nodeId == null) throw new NullPointerException("nodeId is null"); + if (appKey == null) throw new NullPointerException("appKey is null"); + if (channelId < 0) throw new IllegalArgumentException("Negative channelId: " + channelId); + + this.nodeId = nodeId; + this.appKey = appKey; + this.channelId = channelId; + this.thisNodeWasOpener = thisNodeWasOpener; + } + + public static ChannelToken fromString(AppKey expectedAppKey, String tokenString) + throws InvalidChannelTokenException { + if (expectedAppKey == null) throw new NullPointerException("expectedAppKey is null"); + if (tokenString == null || !tokenString.startsWith(TOKEN_PREFIX)) { + throw new InvalidChannelTokenException("Invalid token prefix"); + } + + try { + byte[] data = Base64.decode(tokenString.substring(TOKEN_PREFIX.length()), Base64.DEFAULT); + ChannelTokenProto proto = ChannelTokenProto.parseFrom(data); + + if (proto.nodeId == null || proto.packageName == null || + proto.signatureDigest == null || proto.channelId < 0) { + throw new InvalidChannelTokenException("Missing required fields"); + } + +// AppKey tokenAppKey = new AppKey(proto.packageName, proto.signatureDigest); +// if (!expectedAppKey.equals(tokenAppKey)) { +// throw new InvalidChannelTokenException("AppKey mismatch"); +// } + + if (!expectedAppKey.packageName.equals(proto.packageName)) { + throw new InvalidChannelTokenException( + "Package mismatch: expected=" + expectedAppKey.packageName + + " token=" + proto.packageName); + } + + return new ChannelToken( + proto.nodeId, + new AppKey(proto.packageName, proto.signatureDigest), + proto.channelId, + proto.thisNodeWasOpener + ); + } catch (InvalidChannelTokenException e) { + throw e; + } catch (Exception e) { + throw new InvalidChannelTokenException("Failed to parse token", e); + } + } + + public String toTokenString() { + ChannelTokenProto proto = new ChannelTokenProto(); + proto.nodeId = nodeId; + proto.packageName = appKey.packageName; + proto.signatureDigest = appKey.signatureDigest; + proto.channelId = channelId; + proto.thisNodeWasOpener = thisNodeWasOpener; + + return TOKEN_PREFIX + Base64.encodeToString(proto.toByteArray(), Base64.NO_WRAP); + } + + public ChannelParcelable toParcelable(String path) { + return new ChannelParcelable(toTokenString(), nodeId, path); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (!(obj instanceof ChannelToken)) return false; + ChannelToken other = (ChannelToken) obj; + return channelId == other.channelId + && thisNodeWasOpener == other.thisNodeWasOpener + && appKey.equals(other.appKey) + && nodeId.equals(other.nodeId); + } + + @Override + public int hashCode() { + int result = ((nodeId.hashCode() + 527) * 31) + appKey.hashCode(); + result = (result * 31) + Long.hashCode(channelId); + return (result * 31) + (thisNodeWasOpener ? 1 : 0); + } + + @Override + public String toString() { + return "ChannelToken[nodeId='" + nodeId + "', appKey=" + appKey + + ", channelId=" + channelId + ", thisNodeWasOpener=" + thisNodeWasOpener + "]"; + } + + static class ChannelTokenProto { + String nodeId; + String packageName; + String signatureDigest; + long channelId; + boolean thisNodeWasOpener; + boolean isReliable; + + static ChannelTokenProto parseFrom(byte[] data) throws Exception { + ChannelTokenProto proto = new ChannelTokenProto(); + java.io.DataInputStream dis = new java.io.DataInputStream( + new java.io.ByteArrayInputStream(data)); + proto.nodeId = dis.readUTF(); + proto.packageName = dis.readUTF(); + proto.signatureDigest = dis.readUTF(); + proto.channelId = dis.readLong(); + proto.thisNodeWasOpener = dis.readBoolean(); + + if (dis.available() > 0) { + proto.isReliable = dis.readBoolean(); + } else { + proto.isReliable = true; + } + + return proto; + } + + byte[] toByteArray() { + try { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + java.io.DataOutputStream dos = new java.io.DataOutputStream(baos); + dos.writeUTF(nodeId); + dos.writeUTF(packageName); + dos.writeUTF(signatureDigest); + dos.writeLong(channelId); + dos.writeBoolean(thisNodeWasOpener); + dos.writeBoolean(isReliable); + return baos.toByteArray(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTransport.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTransport.java new file mode 100644 index 0000000000..57f24c780d --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTransport.java @@ -0,0 +1,175 @@ +package org.microg.gms.wearable.channel; + +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.EOFException; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ChannelTransport { + private static final String TAG = "ChannelTransport"; + + private final Map fdModes = new ConcurrentHashMap<>(); +// private final Map inputStreams = new ConcurrentHashMap<>(); + private final Map fdReaders = new ConcurrentHashMap<>(); + private final Map outputStreams = new ConcurrentHashMap<>(); + + private static final byte[] EOF_SENTINEL = new byte[0]; + + private static class FdReader { + final LinkedBlockingDeque queue = new LinkedBlockingDeque<>(); + final AtomicBoolean eof = new AtomicBoolean(false); + final Thread thread; + + FdReader(final FileInputStream fis, final ParcelFileDescriptor fd) { + thread = new Thread(new Runnable() { + @Override + public void run() { + byte[] buf = new byte[65536]; + try { + int n; + while ((n = fis.read(buf)) != -1) { + if (n > 0) { + byte[] chunk = new byte[n]; + System.arraycopy(buf, 0, chunk, 0, n); + queue.put(chunk); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { + } finally { + eof.set(true); + try { + queue.put(EOF_SENTINEL); + } catch (InterruptedException ignored) {} + } + } + }, "ChannelReader-" + fd); + thread.setDaemon(true); + thread.start(); + } + } + + public enum IOMode { + NONE, + READ, + WRITE + } + + public void register(ParcelFileDescriptor fd) { + if (fd != null) { + fdModes.put(fd, IOMode.NONE); + +// inputStreams.put(fd, new FileInputStream(fd.getFileDescriptor())); + fdReaders.put(fd, new FdReader(new FileInputStream(fd.getFileDescriptor()), fd)); + outputStreams.put(fd, new FileOutputStream(fd.getFileDescriptor())); + Log.d(TAG, "Registered FD: " + fd); + } + } + + public void unregister(ParcelFileDescriptor fd) { + if (fd != null) { + fdModes.remove(fd); +// FileInputStream fis = inputStreams.remove(fd); +// if (fis != null) { +// try { +// fis.close(); +// } catch (IOException ignored) {} +// } + FdReader reader = fdReaders.remove(fd); + if (reader != null) + reader.thread.interrupt(); + FileOutputStream fos = outputStreams.remove(fd); + if (fos != null) { + try { + fos.close(); + } catch (IOException ignored) {} + } + Log.d(TAG, "Unregistered FD: " + fd); + } + } + + public void setMode(ParcelFileDescriptor fd, IOMode mode) { + if (fd != null) { + fdModes.put(fd, mode); + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "Set FD mode to " + mode + ": " + fd); + } + } + } + + public int read(ParcelFileDescriptor fd, byte[] buf, int offset, int len) throws IOException { + if (fd == null || !fd.getFileDescriptor().valid()) { + throw new IOException("Invalid file descriptor"); + } + + if (len == 0) + return 0; + + FdReader reader = fdReaders.get(fd); + if (reader == null) + throw new IOException("No reader for FD"); + + byte[] chunk = reader.queue.peek(); + if (chunk == null) + return reader.eof.get() ? -1 : 0; + + if (chunk == EOF_SENTINEL) + return -1; + + chunk = reader.queue.poll(); + if (chunk == null || chunk == EOF_SENTINEL) + return -1; + + int n = Math.min(len, chunk.length); + System.arraycopy(chunk, 0, buf, offset, n); + + if (n < chunk.length) { + byte[] remainder = new byte[chunk.length - n]; + System.arraycopy(chunk, n, remainder, 0, remainder.length); + reader.queue.offerFirst(remainder); + } + + Log.d(TAG, "read: Read " + n + " bytes from FD"); + return n; + } + + public int write(ParcelFileDescriptor fd, byte[] buf, int offset, int len) throws IOException { + if (fd == null || !fd.getFileDescriptor().valid()) { + throw new IOException("Invalid file descriptor"); + } + + try { + FileOutputStream fos = outputStreams.get(fd); + if (fos == null) throw new IOException("No cached stream for FD"); + fos.write(buf, offset, len); + fos.flush(); + + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "Wrote " + len + " bytes to FD"); + } + + return len; + + } catch (EOFException e) { + return -1; + } + } + + public void clear() { + fdModes.clear(); +// inputStreams.clear(); + for (FdReader reader : fdReaders.values()) { + reader.thread.interrupt(); + } + fdReaders.clear(); + outputStreams.clear(); + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/InvalidChannelTokenException.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/InvalidChannelTokenException.java new file mode 100644 index 0000000000..2c2259f53f --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/InvalidChannelTokenException.java @@ -0,0 +1,7 @@ +package org.microg.gms.wearable.channel; + +public class InvalidChannelTokenException extends Exception { + public InvalidChannelTokenException() { super(); } + public InvalidChannelTokenException(String message) { super(message); } + public InvalidChannelTokenException(String message, Throwable cause) { super(message, cause); } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelControlTask.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelControlTask.java new file mode 100644 index 0000000000..f332e8835d --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelControlTask.java @@ -0,0 +1,381 @@ +package org.microg.gms.wearable.channel; + +import android.os.IBinder; +import android.util.Log; + +import org.microg.gms.wearable.WearableConnection; +import org.microg.gms.wearable.proto.AppKey; +import org.microg.gms.wearable.proto.ChannelControlRequest; +import org.microg.gms.wearable.proto.Request; + +import java.io.IOException; + +public class OnChannelControlTask extends ChannelTask { + private static final String TAG = "OnChannelControlTask"; + + private static final ChannelAssetApiEnum DEFAULT_INBOUND_ORIGIN = + ChannelAssetApiEnum.ORIGIN_CHANNEL_API; + + private final String sourceNodeId; + private final WearableConnection connection; + private final Request request; + + public OnChannelControlTask(ChannelManager manager, String sourceNodeId, + WearableConnection connection, Request request) { + super(manager); + this.sourceNodeId = sourceNodeId; + this.connection = connection; + this.request = request; + } + + @Override + protected void execute() throws IOException, ChannelException { + ChannelControlRequest control = request.request.channelControlRequest; + + if (control == null) { + Log.w(TAG, "Channel control request is null"); + return; + } + + int type = control.type; + Log.d(TAG, "onChannelControlReceived: type=" + type + + ", channelId=" + control.channelId + + ", from=" + sourceNodeId); + + switch (type) { + case ChannelManager.CHANNEL_CONTROL_TYPE_OPEN: + handleChannelOpen(control); + break; + case ChannelManager.CHANNEL_CONTROL_TYPE_OPEN_ACK: + handleChannelOpenAck(control); + break; + case ChannelManager.CHANNEL_CONTROL_TYPE_CLOSE: + handleChannelClose(control); + break; + default: + Log.w(TAG, "Unknown channel control type: " + type); + } + } + + private void handleChannelOpen(ChannelControlRequest control) throws IOException, ChannelException { + Log.d(TAG, "handleChannelOpen: channelId=" + control.channelId + + ", path=" + control.path + ", from=" + sourceNodeId); + + if (connection == null) { + Log.w(TAG, "Received channel open from null connection: " + sourceNodeId); + throw new ChannelException(null, "Connection not active"); + } + + if (control.packageName == null || control.packageName.isEmpty()) { + Log.w(TAG, "Channel open missing package name"); + throw new ChannelException(null, "Missing package name"); + } + +// if (control.signatureDigest == null || control.signatureDigest.isEmpty()) { +// Log.w(TAG, "Channel open missing signature digest"); +// throw new ChannelException(null, "Missing signature digest"); +// } + + if (control.path == null || control.path.isEmpty()) { + Log.w(TAG, "Channel open missing path"); + throw new ChannelException(null, "Missing path"); + } + + String sigDigest = control.signatureDigest != null ? control.signatureDigest : ""; + AppKey rawKey = new AppKey(control.packageName, sigDigest); + AppKey resolvedKey = channelManager.trustedPeers.resolveAppKey(sourceNodeId, rawKey); + if (!resolvedKey.equals(rawKey)) { + Log.d(TAG, "Trusted-peer resolution: " + rawKey.packageName + + " → " + resolvedKey.packageName + " for node " + sourceNodeId); + } + + boolean isReliable = control.isReliable != null ? control.isReliable : true; + + ChannelToken token = new ChannelToken(sourceNodeId, resolvedKey, control.channelId, false); + + ChannelStateMachine channel = channelManager.channelTable.get(token); + + if (channel != null) { + handleDuplicateChannelOpen(channel, control); + return; + } + + if (!checkChannelLimits(sourceNodeId, resolvedKey)) { + Log.w(TAG, "Channel limit reached for " + sourceNodeId + "/" + resolvedKey.packageName); + sendOpenError(token, control.path, ChannelStatusCodes.CHANNEL_LIMIT_REACHED); + return; + } + + ChannelAssetApiEnum origin = inferOrigin(control); + ChannelCallbacks callbacks = channelManager.getCallbacksOrNull(origin); + + IBinder.DeathRecipient deathRecipient = () -> onChannelBinderDied(token); + + channel = new ChannelStateMachine( + token, + channelManager, + channelManager.getTransport(), + callbacks, + origin, + isReliable, + false, + deathRecipient, + channelManager.getHandler() + ); + + channel.channelPath = control.path; + channel.setConnectionState(ChannelStateMachine.CONNECTION_STATE_ESTABLISHED); + + channelManager.channelTable.put(token, channel); + + try { + channel.sendOpenAck(); + } catch (IOException e) { + Log.e(TAG, "Failed to send open ACK", e); + channelManager.channelTable.remove(token); + throw e; + } + + scheduleHealthCheck(channel); + + if (callbacks != null) { + try { + callbacks.onChannelOpened(token, control.path); + } catch (Exception e) { + Log.e(TAG, "Error in channel opened callback", e); + } + } + + Log.d(TAG, "Channel opened successfully: " + channel); + } + + private void handleDuplicateChannelOpen(ChannelStateMachine existingChannel, + ChannelControlRequest control) throws IOException, ChannelException { + Log.d(TAG, "Received duplicate OPEN for existing channel: " + existingChannel.token); + + setChannel(existingChannel); + + if (existingChannel.connectionState == ChannelStateMachine.CONNECTION_STATE_ESTABLISHED) { + if (!existingChannel.channelPath.equals(control.path)) { + Log.w(TAG, "Duplicate OPEN with different path. Expected: " + + existingChannel.channelPath + ", got: " + control.path); + throw new ChannelException(existingChannel.token, "Path mismatch on duplicate OPEN"); + } + + Log.d(TAG, "Resending ACK for duplicate OPEN request"); + existingChannel.sendOpenAck(); + } else { + Log.w(TAG, "Received OPEN for channel in state: " + + ChannelStateMachine.getConnectionStateString(existingChannel.connectionState)); + throw new ChannelException(existingChannel.token, + "Channel exists in unexpected state: " + existingChannel.connectionState); + } + } + + private boolean checkChannelLimits(String nodeId, AppKey appKey) { + final int MAX_PER_NODE = 20; + final int MAX_PER_APP = 10; + int forNode = 0, forApp = 0; + for (ChannelStateMachine ch : channelManager.channelTable.values()) { + if (ch.token.nodeId.equals(nodeId)) { + forNode++; + if (ch.token.appKey.equals(appKey)) forApp++; + } + } + if (forNode >= MAX_PER_NODE) { + Log.w(TAG, "Node " + nodeId + " hit channel limit: " + forNode); + return false; + } + if (forApp >= MAX_PER_APP) { + Log.w(TAG, "App " + appKey.packageName + " hit channel limit: " + forApp); + return false; + } + return true; + } + + + private void sendOpenError(ChannelToken token, String path, int errorCode) { + try { + ChannelStateMachine tempChannel = new ChannelStateMachine( + token, + channelManager, + channelManager.getTransport(), + null, + ChannelAssetApiEnum.ORIGIN_CHANNEL_API, + false, + false, + null, + channelManager.getHandler() + ); + + tempChannel.channelPath = path; + tempChannel.sendCloseRequest(errorCode); + } catch (IOException e) { + Log.w(TAG, "Failed to send open error", e); + } + } + + private void scheduleHealthCheck(ChannelStateMachine channel) { + channelManager.getHandler().postDelayed( + () -> performHealthCheck(channel), + 30000 + ); + } + + private void performHealthCheck(ChannelStateMachine channel) { + if (!channelManager.isRunning()) { + return; + } + + ChannelStateMachine current = channelManager.channelTable.get(channel.token); + if (current == null) { + return; + } + + if (current.connectionState == ChannelStateMachine.CONNECTION_STATE_ESTABLISHED) { + long timeSinceCreation = System.currentTimeMillis() - channel.creationTime; + + if (timeSinceCreation > 60000) { + if (current.sendingState == ChannelStateMachine.SENDING_STATE_NOT_STARTED && + current.receivingState == ChannelStateMachine.RECEIVING_STATE_WAITING_FOR_DATA && + !current.hasInputStream() && !current.hasOutputStream()) { + + Log.w(TAG, "Channel appears unused after 1 minute: " + channel.token); + } + } + + channelManager.getHandler().postDelayed( + () -> performHealthCheck(channel), + 60000 + ); + } + } + + private void onChannelBinderDied(ChannelToken token) { + Log.w(TAG, "Channel client died: " + token); + + channelManager.getHandler().post(() -> { + try { + ChannelStateMachine channel = channelManager.channelTable.get(token); + if (channel != null) { + channel.forceClose(); + channelManager.channelTable.remove(token); + } + } catch (Exception e) { + Log.e(TAG, "Error handling binder death", e); + } + }); + } + + + private void handleChannelOpenAck(ChannelControlRequest control) throws ChannelException { + Log.d(TAG, "handleChannelOpenAck: channelId=" + control.channelId); + + ChannelStateMachine channel = channelManager.channelTable.get( + sourceNodeId, control.channelId, ((control.fromChannelOperator == null) || !control.fromChannelOperator)); + + if (channel == null) { + channel = channelManager.channelTable.getByNodeAndId(sourceNodeId, control.channelId); + if (channel != null) { + Log.w(TAG, + "handleChannelOpenAck: fromChannelOperator polarity mismatch for channelId=" + + control.channelId); + } + } + + if (channel == null) { + Log.w(TAG, "Received open ACK for unknown channel: " + control.channelId); + return; + } + + setChannel(channel); + + if (!sourceNodeId.equals(channel.token.nodeId)) { + Log.w(TAG, String.format("Got OPEN_ACK from wrong node for channel %d. Expected %s got %s", + control.channelId, channel.token.nodeId, sourceNodeId)); + return; + } + + if (channel.connectionState != ChannelStateMachine.CONNECTION_STATE_OPEN_SENT) { + Log.w(TAG, "Received OPEN_ACK in wrong state: " + + ChannelStateMachine.getConnectionStateString(channel.connectionState)); + + if (channel.connectionState == ChannelStateMachine.CONNECTION_STATE_ESTABLISHED) { + Log.d(TAG, "Ignoring duplicate OPEN_ACK"); + return; + } + + throw new ChannelException(channel.token, "Received OPEN_ACK in wrong state"); + } + + try { + channel.onChannelOpenAckReceived(); + Log.d(TAG, "Channel established: " + channel); + + scheduleHealthCheck(channel); + + } catch (ChannelException e) { + Log.e(TAG, "Error processing OPEN_ACK", e); + channelManager.channelTable.remove(channel.token); + throw e; + } + } + + private void handleChannelClose(ChannelControlRequest control) throws IOException, ChannelException { + Log.d(TAG, "handleChannelClose: channelId=" + control.channelId); + boolean expectedIsOpener = !control.fromChannelOperator; + ChannelStateMachine channel = channelManager.channelTable.get( + sourceNodeId, control.channelId, expectedIsOpener); + + if (channel == null) { + channel = channelManager.channelTable.getByNodeAndId(sourceNodeId, control.channelId); + if (channel != null) { + Log.w(TAG, "handleChannelClose: fromChannelOperator polarity mismatch for channelId=" + + control.channelId + ", expectedIsOpener=" + expectedIsOpener + + ", actual token.thisNodeWasOpener=" + channel.token.thisNodeWasOpener); + } + } + + if (channel == null) { + Log.w(TAG, "Received close for unknown channel: " + control.channelId); + try { + AppKey appKey = new AppKey( + control.packageName != null ? control.packageName : "com.google.android.gms", + control.signatureDigest != null ? control.signatureDigest : "" + ); + ChannelToken ghostToken = new ChannelToken( + sourceNodeId, appKey, control.channelId, !control.fromChannelOperator + ); + ChannelStateMachine ghost = new ChannelStateMachine( + ghostToken, channelManager, channelManager.getTransport(), + null, ChannelAssetApiEnum.ORIGIN_CHANNEL_API, + false, false, null, channelManager.getHandler() + ); + int errCode = control.closeErrorCode != null ? control.closeErrorCode : 0; + ghost.sendCloseRequest(errCode); + } catch (IOException e) { + Log.w(TAG, "Failed to send close echo for unknown channel", e); + } + return; + } + + setChannel(channel); + + int errorCode = control.closeErrorCode; + channel.onRemoteCloseReceived(errorCode); + Log.d(TAG, "Channel closed by remote: " + channel.token); + channelManager.channelTable.remove(channel.token); + } + + private ChannelAssetApiEnum inferOrigin(ChannelControlRequest ctrl) { + if (channelManager.getCallbacksOrNull(ChannelAssetApiEnum.ORIGIN_LARGE_ASSET_API) != null) { + String path = ctrl.path != null ? ctrl.path : ""; + if (path.startsWith("/asset/") || path.startsWith("/largeAsset/")) { + return ChannelAssetApiEnum.ORIGIN_LARGE_ASSET_API; + } + } + return DEFAULT_INBOUND_ORIGIN; + } + + +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelDataAckTask.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelDataAckTask.java new file mode 100644 index 0000000000..bbe37fac15 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelDataAckTask.java @@ -0,0 +1,52 @@ +package org.microg.gms.wearable.channel; + +import android.util.Log; + +import org.microg.gms.wearable.proto.ChannelDataAckRequest; +import org.microg.gms.wearable.proto.ChannelDataHeader; + +public class OnChannelDataAckTask extends ChannelTask { + private static final String TAG = "OnChannelDataAckTask"; + + private final String sourceNodeId; + private final ChannelDataAckRequest ackRequest; + + public OnChannelDataAckTask(ChannelManager manager, String sourceNodeId, ChannelDataAckRequest ackRequest) { + super(manager); + this.ackRequest = ackRequest; + this.sourceNodeId = sourceNodeId; + } + + @Override + protected void execute() throws ChannelException { + if (ackRequest.header == null) { + Log.w(TAG, "Received data ACK with null header"); + return; + } + + ChannelDataHeader header = ackRequest.header; + Log.d(TAG, "onChannelDataAckReceived: channelId=" + header.channelId + + ", from=" + sourceNodeId); + + ChannelStateMachine channel = channelManager.channelTable.get( + sourceNodeId, + header.channelId, + // If we receive and ack, the message does not come from the operator, + // but we have that stored with operator set to true (and vice versa) + !header.fromChannelOperator + ); + + if (channel == null) { + Log.w(TAG, "Received ACK for unknown channel: " + header.channelId + + " from node: " + sourceNodeId); + return; + } + + setChannel(channel); + + long requestId = header.requestId; + boolean isFinal = ackRequest.finalMessage; + + channel.onDataAckReceived(requestId, isFinal); + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelDataTask.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelDataTask.java new file mode 100644 index 0000000000..9ecf56131a --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelDataTask.java @@ -0,0 +1,56 @@ +package org.microg.gms.wearable.channel; + +import android.util.Log; + +import org.microg.gms.wearable.proto.ChannelDataHeader; +import org.microg.gms.wearable.proto.ChannelDataRequest; + +import java.io.IOException; + +public class OnChannelDataTask extends ChannelTask { + private static final String TAG = "OnChannelDataTask"; + + private final String sourceNodeId; + private final ChannelDataRequest dataRequest; + + public OnChannelDataTask(ChannelManager manager, String sourceNodeId, + ChannelDataRequest dataRequest) { + super(manager); + this.sourceNodeId = sourceNodeId; + this.dataRequest = dataRequest; + } + + @Override + protected void execute() throws IOException, ChannelException { + if (dataRequest.header == null) { + Log.w(TAG, "Received data request with null header"); + return; + } + + ChannelDataHeader header = dataRequest.header; + Log.d(TAG, "onChannelDataReceived: channelId=" + header.channelId + + ", size=" + (dataRequest.payload != null ? dataRequest.payload.size() : 0)); + + ChannelStateMachine channel = channelManager.channelTable.get( + sourceNodeId, header.channelId, !header.fromChannelOperator); + + if (channel == null) { + Log.w(TAG, "Received data for unknown channel: " + header.channelId); + return; + } + + setChannel(channel); + + byte[] data = dataRequest.payload != null ? + dataRequest.payload.toByteArray() : new byte[0]; + boolean isFinal = dataRequest.finalMessage; + long requestId = header.requestId; + + try { + channel.onDataReceived(data, isFinal, requestId); + } catch (ChannelException e) { + Log.w(TAG, "execute: onDataReceived rejected requestId=" + requestId + + ": " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OpenChannelCallback.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OpenChannelCallback.java new file mode 100644 index 0000000000..00c12a45ae --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OpenChannelCallback.java @@ -0,0 +1,5 @@ +package org.microg.gms.wearable.channel; + +public interface OpenChannelCallback { + void onResult(int statusCode, ChannelToken token, String path); +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/PendingOperation.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/PendingOperation.java new file mode 100644 index 0000000000..a95ff1859f --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/PendingOperation.java @@ -0,0 +1,47 @@ +package org.microg.gms.wearable.channel; + +import android.os.Handler; +import android.util.Log; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class PendingOperation { + private static final String TAG = "PendingOperation"; + + private final Handler handler; + private final Runnable timeoutRunnable; + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private final String description; + + public PendingOperation(Handler handler, Runnable onTimeout, long timeoutMs, String description) { + this.handler = handler; + this.timeoutRunnable = onTimeout; + this.description = description; + + handler.postDelayed(timeoutRunnable, timeoutMs); + + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "Created pending operation: " + description + " (timeout=" + timeoutMs + "ms)"); + } + } + + public boolean cancel() { + if (cancelled.compareAndSet(false, true)) { + handler.removeCallbacks(timeoutRunnable); + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "Cancelled pending operation: " + description); + } + return true; + } + return false; + } + + public boolean isCancelled() { + return cancelled.get(); + } + + @Override + public String toString() { + return "PendingOperation{" + description + ", cancelled=" + cancelled.get() + "}"; + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/TrustedPeersService.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/TrustedPeersService.java new file mode 100644 index 0000000000..af89135f2d --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/TrustedPeersService.java @@ -0,0 +1,201 @@ +package org.microg.gms.wearable.channel; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.util.Log; + +import org.microg.gms.wearable.proto.AppKey; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import okio.ByteString; + +public class TrustedPeersService { + private static final String TAG = "TrustedPeersService"; + private static final String META_TRUSTED_PACKAGES = "wear-trusted-peer-packages"; + + private final Context context; + + private final ConcurrentHashMap> localTrustMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap>> remotePeerMaps = new ConcurrentHashMap<>(); + + private final ConcurrentHashMap> resolvedPairs = new ConcurrentHashMap<>(); + + public TrustedPeersService(Context context) { + this.context = context.getApplicationContext(); + } + public AppKey resolveAppKey(String nodeId, AppKey appKey) { + Map pairs = resolvedPairs.get(nodeId); + if (pairs != null) { + for (Map.Entry entry : pairs.entrySet()) { + if (appKey.equals(entry.getValue())) { + Log.d(TAG, "Resolved trusted peer " + appKey.packageName + + " → " + entry.getKey().packageName + " for node " + nodeId); + return entry.getKey(); + } + } + } + return appKey; + } + + public Set getTrustedRemoteKeysFor(AppKey localAppKey) { + Set set = localTrustMap.get(localAppKey); + return set != null ? Collections.unmodifiableSet(set) : Collections.emptySet(); + } + + public void onPackageUpdated(String packageName) { + try { + PackageInfo pi = context.getPackageManager() + .getPackageInfo(packageName, PackageManager.GET_META_DATA | PackageManager.GET_SIGNATURES); + AppKey localKey = buildAppKey(pi); + if (localKey == null) return; + + ApplicationInfo ai = pi.applicationInfo; + if (ai == null || ai.metaData == null || !ai.metaData.containsKey(META_TRUSTED_PACKAGES)) { + localTrustMap.remove(localKey); + recalculateAllNodes(); + return; + } + + String raw = ai.metaData.getString(META_TRUSTED_PACKAGES); + if (raw == null || raw.isEmpty()) { + localTrustMap.remove(localKey); + recalculateAllNodes(); + return; + } + + Set trusted = parseTrustedPackages(raw); + if (trusted.isEmpty()) { + localTrustMap.remove(localKey); + } else { + localTrustMap.put(localKey, trusted); + Log.d(TAG, "Loaded " + trusted.size() + " trusted peers for " + packageName); + } + recalculateAllNodes(); + + } catch (PackageManager.NameNotFoundException e) { + Log.d(TAG, "Package not found during trust scan: " + packageName); + Iterator it = localTrustMap.keySet().iterator(); + while (it.hasNext()) { + AppKey key = it.next(); + if (key.packageName.equals(packageName)) { + it.remove(); + } + } + recalculateAllNodes(); + } + } + + public void onPackageRemoved(String packageName) { + boolean changed = false; + + Iterator>> it = localTrustMap.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry> entry = it.next(); + if (entry.getKey().packageName.equals(packageName)) { + it.remove(); + changed = true; + } + } + + if (changed) recalculateAllNodes(); + } + + public void updateRemotePeerMap(String nodeId, Map> remoteMap) { + if (remoteMap == null || remoteMap.isEmpty()) { + remotePeerMaps.remove(nodeId); + } else { + remotePeerMaps.put(nodeId, remoteMap); + } + recalculateNode(nodeId); + } + + public void onNodeDisconnected(String nodeId) { + remotePeerMaps.remove(nodeId); + resolvedPairs.remove(nodeId); + } + + private void recalculateAllNodes() { + for (String nodeId : remotePeerMaps.keySet()) { + recalculateNode(nodeId); + } + resolvedPairs.keySet().retainAll(remotePeerMaps.keySet()); + } + + private void recalculateNode(String nodeId) { + Map> remoteMap = remotePeerMaps.get(nodeId); + if (remoteMap == null || remoteMap.isEmpty()) { + resolvedPairs.remove(nodeId); + return; + } + + ConcurrentHashMap resolved = new ConcurrentHashMap<>(); + + for (Map.Entry> localEntry : localTrustMap.entrySet()) { + AppKey localKey = localEntry.getKey(); + for (AppKey remoteCandidate : localEntry.getValue()) { + Set remotelyTrusted = remoteMap.get(remoteCandidate); + if (remotelyTrusted != null && remotelyTrusted.contains(localKey)) { + resolved.put(localKey, remoteCandidate); + Log.d(TAG, "Resolved bidirectional trust: " + + localKey.packageName + " ↔ " + remoteCandidate.packageName + + " on node " + nodeId); + break; + } + } + } + + if (resolved.isEmpty()) { + resolvedPairs.remove(nodeId); + } else { + resolvedPairs.put(nodeId, resolved); + } + } + + private static Set parseTrustedPackages(String raw) { + Set result = new HashSet<>(); + for (String entry : raw.split(",")) { + entry = entry.trim(); + int colon = entry.lastIndexOf(':'); + if (colon < 1 || colon == entry.length() - 1) { + Log.w(TAG, "Skipping malformed trusted-package entry: " + entry); + continue; + } + String pkg = entry.substring(0, colon).trim(); + String digest = entry.substring(colon + 1).trim().toLowerCase(Locale.ROOT); + if (!pkg.isEmpty() && !digest.isEmpty()) { + result.add(new AppKey(pkg, digest)); + } + } + return result; + } + + private static AppKey buildAppKey(PackageInfo pi) { + if (pi.signatures == null || pi.signatures.length == 0) return null; + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(pi.signatures[0].toByteArray()); + return new AppKey(pi.packageName, bytesToHex(digest)); + } catch (java.security.NoSuchAlgorithmException e) { + Log.e(TAG, "SHA-1 unavailable", e); + return null; + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/NetworkConnectionManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/NetworkConnectionManager.java new file mode 100644 index 0000000000..da4f861ddb --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/NetworkConnectionManager.java @@ -0,0 +1,202 @@ +package org.microg.gms.wearable.network; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.Log; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.WearableImpl; +import org.microg.gms.wearable.proto.RootMessage; + +import java.util.HashMap; +import java.util.Map; + +public class NetworkConnectionManager implements Cloneable { + private static final String TAG = "GmsWearNetMgr"; + + private static final long SHUTDOWN_JOIN_TIMEOUT = 5000; + + private final Context context; + private final WearableImpl wearable; + + private final Map threads = new HashMap<>(); + private final Map configs = new HashMap<>(); + + private final BroadcastReceiver connectivityReceiver; + private volatile boolean shutdown = false; + + public NetworkConnectionManager(Context context, WearableImpl wearable) { + this.context = context; + this.wearable = wearable; + + connectivityReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (!ConnectivityManager.EXTRA_NO_CONNECTIVITY.equals(intent.getAction())) { + return; + } + + if (intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false)) { + return; + } + + onNetworkAvailable(); + } + }; + + context.registerReceiver( + connectivityReceiver, + new IntentFilter(ConnectivityManager.EXTRA_NO_CONNECTIVITY) + ); + + Log.d(TAG, "initialised"); + } + + public synchronized void addConfig(ConnectionConfiguration config) { + if (shutdown) { + Log.w(TAG, "Manager is shut down, ignoring addConfig for " + config.address); + return; + } + validateConfig(config); + + String addr = config.address; + configs.put(addr, config); + + NetworkConnectionThread existing = threads.get(addr); + if (existing != null) { + if (existing.isAlive() && !existing.isInterrupted()) { + Log.d(TAG, "Thread already active for " + addr + ", triggering retry"); + existing.triggerRetry(); + } else { + Log.d(TAG, "Replacing dead thread for " + addr); + existing.close(); + threads.remove(addr); + startThread(config); + } + return; + } + + if (isNetworkAvailable()) { + startThread(config); + } else { + Log.d(TAG, "Network unavailable, deferring connection for " + addr); + } + } + + public synchronized void removeConfig(ConnectionConfiguration config) { + if (shutdown) return; + validateConfig(config); + + String addr = config.address; + configs.remove(addr); + + NetworkConnectionThread t = threads.remove(addr); + if (t != null) { + Log.d(TAG, "Removing thread for " + addr); + t.close(); + joinThread(t, addr); + } + } + + public synchronized void sendMessage(String address, RootMessage message) { + NetworkConnectionThread t = threads.get(address); + if (t == null || !t.isAlive()) { + throw new IllegalArgumentException("No active connection for " + address); + } + t.sendMessage(message); + } + + public synchronized boolean hasConnection(String address) { + NetworkConnectionThread t = threads.get(address); + return t != null && t.isAlive() && !t.isInterrupted(); + } + + private void onNetworkAvailable() { + synchronized (this) { + if (shutdown) return; + Log.d(TAG, "Network available — checking " + configs.size() + " config(s)"); + + for (Map.Entry entry : configs.entrySet()) { + String addr = entry.getKey(); + NetworkConnectionThread t = threads.get(addr); + + if (t == null || !t.isAlive()) { + Log.d(TAG, "Starting thread for " + addr + " (network regained)"); + if (t != null) threads.remove(addr); + startThread(entry.getValue()); + } else { + t.triggerRetry(); + } + } + } + } + + @SuppressWarnings("deprecation") + private boolean isNetworkAvailable() { + ConnectivityManager cm = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) return false; + NetworkInfo info = cm.getActiveNetworkInfo(); + return info != null && info.isConnected(); + } + + public synchronized void close() { + if (shutdown) return; + shutdown = true; + Log.d(TAG, "Shutting down NetworkConnectionManager (" + threads.size() + " thread(s))"); + + try { + context.unregisterReceiver(connectivityReceiver); + } catch (Exception e) { + Log.w(TAG, "Error unregistering connectivity receiver", e); + } + + for (NetworkConnectionThread t : threads.values()) t.close(); + for (Map.Entry e : threads.entrySet()) { + joinThread(e.getValue(), e.getKey()); + } + + threads.clear(); + configs.clear(); + Log.d(TAG, "NetworkConnectionManager shut down"); + } + + private void startThread(ConnectionConfiguration config) { + NetworkConnectionThread t = + new NetworkConnectionThread(context, config, wearable); + threads.put(config.address, t); + t.start(); + Log.d(TAG, "Started NetworkConnectionThread for " + config.address); + } + + private static void joinThread(NetworkConnectionThread t, String address) { + try { + t.join(SHUTDOWN_JOIN_TIMEOUT); + if (t.isAlive()) { + Log.w(TAG, "Thread for " + address + " did not stop within " + + SHUTDOWN_JOIN_TIMEOUT + "ms"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void validateConfig(ConnectionConfiguration config) { + if (config == null || config.address == null) { + throw new IllegalArgumentException("Config or address is null"); + } + if (config.type != WearableImpl.TYPE_NETWORK) { + throw new IllegalArgumentException("Expected TYPE_NETWORK, got type=" + config.type); + } + if (config.role != WearableImpl.ROLE_CLIENT) { + throw new IllegalArgumentException("Expected ROLE_CLIENT, got role=" + config.role); + } + } + + +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/NetworkConnectionThread.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/NetworkConnectionThread.java new file mode 100644 index 0000000000..2ee4c1bf82 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/NetworkConnectionThread.java @@ -0,0 +1,240 @@ +package org.microg.gms.wearable.network; + +import static org.microg.gms.wearable.WearableImpl.ROLE_CLIENT; + +import android.content.Context; +import android.provider.Settings; +import android.util.Log; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.profile.Build; +import org.microg.gms.wearable.ConnectHandshake; +import org.microg.gms.wearable.MessageHandler; +import org.microg.gms.wearable.SocketWearableConnection; +import org.microg.gms.wearable.TransportConnectionHandler; +import org.microg.gms.wearable.WearableConnection; +import org.microg.gms.wearable.WearableImpl; +import org.microg.gms.wearable.WearableReader; +import org.microg.gms.wearable.WearableWriter; +import org.microg.gms.wearable.bluetooth.RetryStrategy; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.MessagePiece; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public class NetworkConnectionThread extends Thread implements Cloneable{ + private static final String TAG = "GmsWearNetThread"; + + private static final int CONNECT_TIMEOUT = 30000; + private static final long MIN_ATTEMPT_INTERVAL = 3000; + + private final Context context; + private final ConnectionConfiguration config; + private final WearableImpl wearable; + + private final RetryStrategy retryStrategy; + + private final Lock lock = new ReentrantLock(); + + private final Condition retryCondition = lock.newCondition(); + private final AtomicBoolean running = new AtomicBoolean(true); + private final AtomicBoolean immediateRetry = new AtomicBoolean(false); + + private volatile Socket activeSocket; + private volatile WearableWriter activeWriter; + private long lastAttemptTime = 0; + + public NetworkConnectionThread(Context context, ConnectionConfiguration config, WearableImpl wearable) { + super("NetThread-" + config.address); + this.config = config; + this.context = context; + this.wearable = wearable; + this.retryStrategy = RetryStrategy.fromPolicy(config.connectionRetryStrategy); + } + + @Override + public void run() { + Log.d(TAG, "Started for " + config.address); + + while (running.get() && !isInterrupted()) { + try { + enforceMinInterval(); + if (!running.get()) break; + + connect(); + retryStrategy.reset(); + } catch (IOException e) { + Log.w(TAG, "Connection failed to " + config.address + ": " + e.getMessage()); + } catch (InterruptedException e ) { + if (!running.get()) break; + } catch (Exception e){ + Log.e(TAG, "Unexpected error for " + config.address, e); + } finally { + teardown(); + } + + if (running.get() && !isInterrupted()) { + try { + waitForRetry(); + } catch (InterruptedException e) { + if (!running.get()) break; + } + } + } + + Log.d(TAG, "Stopped for " + config.address); + } + + private void connect() throws IOException { + Log.d(TAG, "Connecting to " + config.address + ":" + WearableImpl.WEAR_TCP_PORT); + + Socket socket = new Socket(); + socket.setTcpNoDelay(true); + socket.connect( + new InetSocketAddress(config.address, WearableImpl.WEAR_TCP_PORT), + CONNECT_TIMEOUT + ); + activeSocket = socket; + + Log.d(TAG, "Connected to " + config.address); + + SocketWearableConnection raw = new SocketWearableConnection(socket, null); + ConnectHandshake.perform(raw, + new ConnectHandshake.LocalIdentity( + wearable.getLocalNodeId(), + Build.MODEL, + getAndroidId(), + wearable.getClockworkNodePreferences().getNetworkId(), + config.role == ROLE_CLIENT ? config.packageName : null, + config.migrating, + config.migrating ? wearable.getClockworkNodePreferences().getPeerNodeId() : null)); + new TransportConnectionHandler(wearable, config).handle(raw); + activeWriter = null; + } + + private long getAndroidId() { + try { + String s = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + return s != null ? Long.parseUnsignedLong(s, 16) : 0L; + } catch (NumberFormatException e) { + return 0L; + } + } + + private void enforceMinInterval() throws InterruptedException { + long elapsed = System.currentTimeMillis() - lastAttemptTime; + if (lastAttemptTime > 0 && elapsed < MIN_ATTEMPT_INTERVAL) { + Thread.sleep(MIN_ATTEMPT_INTERVAL - elapsed); + } + lastAttemptTime = System.currentTimeMillis(); + } + + private void waitForRetry() throws InterruptedException { + long delayMs = retryStrategy.nextDelayMs(); + + if (immediateRetry.getAndSet(false)) { + Log.d(TAG, "Immediate retry for " + config.address); + return; + } + + if (delayMs < 0) { + Log.d(TAG, "Retry OFF for " + config.address + ", waiting for external trigger"); + waitExternal(); + return; + } + + Log.d(TAG, "Waiting " + delayMs + "ms before retry to " + config.address); + lock.lock(); + try { + long deadline = System.currentTimeMillis() + delayMs; + while (running.get() && !immediateRetry.get()) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) break; + retryCondition.await(remaining, TimeUnit.MILLISECONDS); + } + immediateRetry.set(false); + } finally { + lock.unlock(); + } + } + + private void waitExternal() throws InterruptedException { + lock.lock(); + try { + while (running.get() && !immediateRetry.get()) { + retryCondition.await(); + } + immediateRetry.set(false); + } finally { + lock.unlock(); + } + } + + private void signalRetry() { + lock.lock(); + try { + immediateRetry.set(true); + retryCondition.signal(); + } finally { + lock.unlock(); + } + } + + public void sendMessage(RootMessage message) { + WearableWriter w = activeWriter; + if (w != null && !w.isClosed()) { + w.enqueue(message); + } else { + Log.w(TAG, "sendMessage() - no active writer for " + config.address); + } + } + + public void retryNow() { + retryStrategy.reset(); + signalRetry(); + } + + public void triggerRetry() { + signalRetry(); + } + + public boolean isConnected() { + WearableWriter w = activeWriter; + return w != null && !w.isClosed(); + } + + public void close() { + Log.d(TAG, "Closing for " + config.address); + running.set(false); + signalRetry(); + interrupt(); + teardown(); + } + + private void teardown() { + WearableWriter w = activeWriter; + if (w != null) { + w.close(); + activeWriter = null; + } + + Socket s = activeSocket; + if (s != null) { + try { + s.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing socket for " + config.address); + } + activeSocket = null; + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/WearableWifiService.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/WearableWifiService.java new file mode 100644 index 0000000000..7b8684b717 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/WearableWifiService.java @@ -0,0 +1,1028 @@ +package org.microg.gms.wearable.network; + +import android.Manifest; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.wifi.WifiConfiguration; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.Process; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; + +import com.google.android.gms.common.api.CommonStatusCodes; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.wearable.DataMap; +import com.google.android.gms.wearable.MessageOptions; +import com.google.android.gms.wearable.WearableStatusCodes; + +import org.microg.gms.wearable.DataItemInternal; +import org.microg.gms.wearable.DataItemRecord; +import org.microg.gms.wearable.RpcMessageTransport; +import org.microg.gms.wearable.WearableImpl; +import org.microg.gms.wearable.proto.ProtoDuration; +import org.microg.gms.wearable.proto.WifiConfigurationInfo; +import org.microg.gms.wearable.proto.WifiConfigurationList; +import org.microg.gms.wearable.proto.WifiConnectRequest; +import org.microg.gms.wearable.proto.WifiConnectResponse; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +public class WearableWifiService { + private static final String TAG = "WearableWifiService"; + + public static final String GMS_PACKAGE = "com.google.android.gms"; + public static final String GMS_SIGNATURE = "38918a453d07199354f8b19af05ec6562ced5788"; + + public static final String PATH_WIFI_SYNC_PROTO = "/wifi_sync_proto"; + public static final String PATH_SYNC_WIFI_CREDENTIALS = "/sync_wifi_credentials"; + public static final String PATH_WIFI_CONNECT_IMMEDIATE = "/wifi_connect_immediate"; + + public static final int RESULT_SUCCESS = 1; + public static final int RESULT_INVALID_REQUEST = 2; + public static final int RESULT_NETWORK_UNAVAILABLE = 3; + public static final int RESULT_UNSUPPORTED_SDK = 4; + + private static final int STATUS_WIFI_CONNECT_UNSUPPORTED = 4009; + private static final int STATUS_WIFI_CONNECT_FAILED = 4015; + + private static final long CONNECT_REQUEST_TIMEOUT_MS = 60_000L; + private static final long CONNECT_RESPONSE_GRACE_MS = 5_000L; + private static final long DEFAULT_CONNECT_TIMEOUT_MS = 300_000L; + + private static final String PREFS_NAME = "wearable.wifi_service"; + private static final String PREF_LAST_SYNC_WRITTEN = "last_sync_dataitem_written"; + private static final String SETUP_WIZARD_SETTING = "setup_wizard_has_run"; + + private static final String ACTION_CONFIGURED_NETWORKS_CHANGE = "android.net.wifi.CONFIGURED_NETWORKS_CHANGE"; + private static final String EXTRA_CHANGE_REASON = "changeReason"; + private static final String EXTRA_WIFI_CONFIGURATION = "wifiConfiguration"; + private static final int CHANGE_REASON_ADDED = 0; + private static final int CHANGE_REASON_CONFIG_CHANGE = 2; + private static final int WIFI_STATE_ENABLED = 3; + private static final String FEATURE_WATCH = "android.hardware.type.watch"; + private static final String UNKNOWN_SSID = ""; + + private static final int SECURITY_NONE = WifiConfiguration.KeyMgmt.NONE; + private static final int SECURITY_WPA_PSK = WifiConfiguration.KeyMgmt.WPA_PSK; + private static final int SECURITY_WPA_EAP = WifiConfiguration.KeyMgmt.WPA_EAP; + + private static final int SECURITY_SAE = WifiConfiguration.KeyMgmt.SAE; + + private static final int SECURITY_OWE = WifiConfiguration.KeyMgmt.OWE; + + private static final int LEGACY_OPEN = 0; + private static final int LEGACY_WEP = 1; + private static final int LEGACY_PSK = 2; + private static final int LEGACY_EAP = 3; + + private static final int MSG_INIT = 1; + private static final int MSG_DATA_ITEM = 2; + private static final int MSG_WIFI_ENABLED = 3; + private static final int MSG_CREDENTIAL_CHANGED = 4; + private static final int MSG_CONNECT = 5; + private static final int MSG_CONNECT_AVAILABLE = 6; + private static final int MSG_CONNECT_UNAVAILABLE = 7; + + public interface StatusCallback { + void onStatus(Status status); + } + + private static final class ConnectRequest { + final boolean requiresResponse; + final int requestId; + final String sourceNodeId; + final WifiConnectRequest request; + + ConnectRequest(boolean requiresResponse, int requestId, String sourceNodeId, WifiConnectRequest request) { + this.requiresResponse = requiresResponse; + this.requestId = requestId; + this.sourceNodeId = sourceNodeId; + this.request = request; + } + } + + private final Context context; + private final WearableImpl wearable; + private final boolean isWatch; + @Nullable + private final WifiManager wifiManager; + @Nullable + private final ConnectivityManager connectivityManager; + private final SharedPreferences prefs; + private final HandlerThread handlerThread; + private final Handler handler; + + private final AtomicBoolean setupComplete = new AtomicBoolean(false); + private final AtomicBoolean sawProtoItem = new AtomicBoolean(false); + private final AtomicBoolean knownNetworksLoaded = new AtomicBoolean(false); + private volatile boolean initialSyncDone = false; + + private final Map knownNetworks = new ConcurrentHashMap<>(); + + private final Object pendingLock = new Object(); + private DataItemRecord pendingRecord; + private ConnectivityManager.NetworkCallback syncNetworkCallback; + + private ConnectivityManager.NetworkCallback connectCallback; + private ConnectRequest activeConnect; + + private BroadcastReceiver savedNetworkReceiver; + private BroadcastReceiver wifiStateReceiver; + private ContentObserver setupObserver; + + public WearableWifiService(Context context, WearableImpl wearable) { + Context app = context.getApplicationContext(); + this.context = app != null ? app : context; + this.wearable = wearable; + this.isWatch = this.context.getPackageManager().hasSystemFeature(FEATURE_WATCH); + this.wifiManager = (WifiManager) this.context.getSystemService(Context.WIFI_SERVICE); + this.connectivityManager = (ConnectivityManager) this.context.getSystemService(Context.CONNECTIVITY_SERVICE); + this.prefs = this.context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + this.handlerThread = new HandlerThread("WearWifiServiceHandler", Process.THREAD_PRIORITY_BACKGROUND); + this.handlerThread.start(); + this.handler = new WifiHandler(handlerThread.getLooper()); + start(); + } + + @Nullable + public WifiManager getWifiManager() { + return wifiManager; + } + + private void start() { + if (!isWatch) { + savedNetworkReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + onSavedNetworksChanged(intent); + } + }; + registerReceiverSafely(savedNetworkReceiver, new IntentFilter(ACTION_CONFIGURED_NETWORKS_CHANGE)); + return; + } + + wifiStateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + if (WifiManager.WIFI_STATE_CHANGED_ACTION.equals(intent.getAction()) + && intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, WifiManager.WIFI_STATE_UNKNOWN) == WIFI_STATE_ENABLED) { + handler.sendEmptyMessage(MSG_WIFI_ENABLED); + } + } + }; + registerReceiverSafely(wifiStateReceiver, new IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION)); + + final ContentResolver resolver = context.getContentResolver(); + if (Settings.System.getInt(resolver, SETUP_WIZARD_SETTING, 0) != 0) { + setupComplete.set(true); + handler.sendEmptyMessage(MSG_INIT); + } else { + Log.d(TAG, "Waiting for setup wizard to complete before syncing wifi credentials"); + setupObserver = new ContentObserver(handler) { + @Override + public void onChange(boolean selfChange) { + if (Settings.System.getInt(resolver, SETUP_WIZARD_SETTING, 0) == 1) { + Log.d(TAG, "Setup wizard completed, performing initial wifi sync"); + setupComplete.set(true); + handler.sendEmptyMessage(MSG_INIT); + } + } + }; + resolver.registerContentObserver(Settings.System.getUriFor(SETUP_WIZARD_SETTING), false, setupObserver); + } + } + + public void stop() { + unregisterReceiverSafely(savedNetworkReceiver); + unregisterReceiverSafely(wifiStateReceiver); + savedNetworkReceiver = null; + wifiStateReceiver = null; + if (setupObserver != null) { + context.getContentResolver().unregisterContentObserver(setupObserver); + setupObserver = null; + } + synchronized (pendingLock) { + unregisterCallbackSafely(syncNetworkCallback); + syncNetworkCallback = null; + pendingRecord = null; + } + handler.post(() -> { + unregisterCallbackSafely(connectCallback); + connectCallback = null; + activeConnect = null; + }); + handlerThread.quitSafely(); + } + + private void registerReceiverSafely(BroadcastReceiver receiver, IntentFilter filter) { + try { + context.registerReceiver(receiver, filter); + } catch (RuntimeException e) { + Log.w(TAG, "Could not register receiver for " + filter.getAction(0), e); + } + } + + private void unregisterReceiverSafely(@Nullable BroadcastReceiver receiver) { + if (receiver == null) return; + try { + context.unregisterReceiver(receiver); + } catch (RuntimeException ignored) { + } + } + + private void unregisterCallbackSafely(@Nullable ConnectivityManager.NetworkCallback callback) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + if (callback == null || connectivityManager == null) return; + try { + connectivityManager.unregisterNetworkCallback(callback); + } catch (RuntimeException ignored) { + } + } + + private final class WifiHandler extends Handler { + WifiHandler(Looper looper) { + super(looper); + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + @Override + public void handleMessage(Message msg) { + try { + switch (msg.what) { + case MSG_INIT: + performInitialSync(); + break; + case MSG_DATA_ITEM: + considerDataItem((DataItemRecord) msg.obj); + break; + case MSG_WIFI_ENABLED: + applyPendingDataItem("WifiOnReceiver"); + break; + case MSG_CREDENTIAL_CHANGED: + syncNewCredential(); + break; + case MSG_CONNECT: + connectImmediately((ConnectRequest) msg.obj); + break; + case MSG_CONNECT_AVAILABLE: + finishConnect(RESULT_SUCCESS); + break; + case MSG_CONNECT_UNAVAILABLE: + finishConnect(RESULT_NETWORK_UNAVAILABLE); + break; + default: + break; + } + } catch (RuntimeException e) { + Log.w(TAG, "Failure handling message " + msg.what, e); + } + } + } + + private void onSavedNetworksChanged(Intent intent) { + if (isWatch || !ACTION_CONFIGURED_NETWORKS_CHANGE.equals(intent.getAction())) return; + int reason = intent.getIntExtra(EXTRA_CHANGE_REASON, CHANGE_REASON_ADDED); + if (reason != CHANGE_REASON_ADDED && reason != CHANGE_REASON_CONFIG_CHANGE) { + Log.d(TAG, "Not syncing credentials for change reason: " + reason); + return; + } + Log.d(TAG, "Wifi credentials " + (reason == CHANGE_REASON_ADDED ? "ADDED" : "CHANGED") + ", syncing..."); + WifiConfiguration changed = null; + try { + changed = intent.getParcelableExtra(EXTRA_WIFI_CONFIGURATION); + } catch (RuntimeException e) { + Log.w(TAG, "Could not read changed configuration", e); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && changed != null && Hidden.isEphemeral(changed)) { + Log.d(TAG, "Not syncing change to ephemeral credential: " + changed.SSID); + return; + } + handler.sendEmptyMessage(MSG_CREDENTIAL_CHANGED); + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + private void syncNewCredential() { + try { + if (!syncCredentials(false)) { + Log.w(TAG, "Failed to sync new wifi credential."); + } + } catch (RuntimeException e) { + Log.w(TAG, "Error trying to sync new wifi credentials", e); + } + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + public boolean syncCredentials(boolean enableWifiTemporarily) { + if (isWatch) return false; + Log.i(TAG, "Syncing Wifi Credentials"); + if (wifiManager == null) { + Log.w(TAG, "No WiFi service. Exit..."); + return false; + } + List networks = getNetworks(enableWifiTemporarily); + if (networks.isEmpty()) return false; + String localNodeId = wearable.getLocalNodeId(); + if (TextUtils.isEmpty(localNodeId)) { + Log.w(TAG, "No local node id, cannot publish credentials"); + return false; + } + Log.d(TAG, "Number of wifi credentials: " + networks.size()); + + List infos = new ArrayList<>(networks.size()); + for (WifiConfiguration config : networks) infos.add(toInfo(config)); + publish(localNodeId, PATH_WIFI_SYNC_PROTO, + new WifiConfigurationList.Builder().networks(infos).build().encode()); + publish(localNodeId, PATH_SYNC_WIFI_CREDENTIALS, buildLegacyPayload(networks)); + return true; + } + + private void publish(String localNodeId, String path, byte[] data) { + DataItemInternal item = new DataItemInternal(localNodeId, path); + item.data = data; + DataItemRecord record = wearable.putDataItem(GMS_PACKAGE, GMS_SIGNATURE, localNodeId, item); + wearable.syncRecordToAll(record); + } + + private static byte[] buildLegacyPayload(List networks) { + ArrayList list = new ArrayList<>(networks.size()); + for (WifiConfiguration config : networks) { + DataMap map = new DataMap(); + map.putString("ssid", unquote(config.SSID)); + if (config.hiddenSSID) map.putInt("hiddenSsid", 1); + if (config.allowedKeyManagement.get(SECURITY_WPA_PSK)) { + map.putInt("key_mgmt", LEGACY_PSK); + map.putString("key", unquote(config.preSharedKey)); + } else if (config.allowedKeyManagement.get(SECURITY_WPA_EAP) + || config.allowedKeyManagement.get(WifiConfiguration.KeyMgmt.IEEE8021X)) { + map.putInt("key_mgmt", LEGACY_EAP); + } else if (config.wepKeys != null && config.wepKeys[0] != null) { + map.putInt("key_mgmt", LEGACY_WEP); + map.putString("key", unquote(config.wepKeys[0])); + } else { + map.putInt("key_mgmt", LEGACY_OPEN); + } + list.add(map); + } + DataMap root = new DataMap(); + root.putDataMapArrayList("list", list); + root.putInt("source", 1); + return root.toByteArray(); + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + public void syncCredentialToNode(final String nodeId, final String ssid, final StatusCallback callback) { + handler.post(() -> { + try { + sendConnectRequest(nodeId, ssid, callback); + } catch (RuntimeException e) { + Log.w(TAG, "syncCredentialToNode failed", e); + callback.onStatus(new Status(CommonStatusCodes.INTERNAL_ERROR)); + } + }); + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + public void syncConnectedCredentialToNode(final String nodeId, final StatusCallback callback) { + handler.post(() -> { + String ssid = null; + try { + WifiInfo info = wifiManager != null ? wifiManager.getConnectionInfo() : null; + if (info != null) ssid = info.getSSID(); + } catch (RuntimeException e) { + Log.w(TAG, "Could not read connection info", e); + } + if (TextUtils.isEmpty(ssid) || UNKNOWN_SSID.equals(ssid)) { + callback.onStatus(new Status(WearableStatusCodes.WIFI_CREDENTIAL_SYNC_NO_CREDENTIAL_FETCHED)); + return; + } + try { + sendConnectRequest(nodeId, ssid, callback); + } catch (RuntimeException e) { + Log.w(TAG, "syncConnectedCredentialToNode failed", e); + callback.onStatus(new Status(CommonStatusCodes.INTERNAL_ERROR)); + } + }); + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + private void sendConnectRequest(String nodeId, String ssid, final StatusCallback callback) { + String quoted = quote(ssid); + WifiConfiguration found = null; + for (WifiConfiguration config : Hidden.privilegedNetworks(wifiManager)) { + if (config.SSID != null && config.SSID.equals(quoted)) { + found = config; + break; + } + } + if (found == null) { + callback.onStatus(new Status(WearableStatusCodes.WIFI_CREDENTIAL_SYNC_NO_CREDENTIAL_FETCHED)); + return; + } + + byte[] payload = new WifiConnectRequest.Builder() + .config(toInfo(found)) + .timeout(toDuration(CONNECT_REQUEST_TIMEOUT_MS)) + .build().encode(); + + final String target = wearable.resolveToWearableNodeId(nodeId); + int requestId = wearable.getRpcTransport().sendRequest(GMS_PACKAGE, GMS_SIGNATURE, target, + PATH_WIFI_CONNECT_IMMEDIATE, payload, new MessageOptions(RpcMessageTransport.API_PRIO_LOW)); + if (requestId == -1) { + callback.onStatus(new Status(WearableStatusCodes.TARGET_NODE_NOT_CONNECTED)); + return; + } + wearable.getRpcHelper().addResponseListener(target, requestId, + CONNECT_REQUEST_TIMEOUT_MS + CONNECT_RESPONSE_GRACE_MS, + data -> { + int status = statusFromResponse(data); + if (status == STATUS_WIFI_CONNECT_FAILED || status == STATUS_WIFI_CONNECT_UNSUPPORTED) { + Log.w(TAG, "Immediate connect failed (" + status + "); credentials still synced via data items"); + status = CommonStatusCodes.SUCCESS; + } + callback.onStatus(new Status(status)); + }, + () -> { + Log.w(TAG, "Immediate connect timed out; relying on data item sync"); + callback.onStatus(new Status(CommonStatusCodes.SUCCESS)); + }); + } + + private static int statusFromResponse(@Nullable byte[] data) { + if (data == null) return CommonStatusCodes.INTERNAL_ERROR; + try { + Integer result = WifiConnectResponse.ADAPTER.decode(data).result; + switch (result != null ? result : 0) { + case RESULT_SUCCESS: + return CommonStatusCodes.SUCCESS; + case RESULT_NETWORK_UNAVAILABLE: + return STATUS_WIFI_CONNECT_FAILED; + case RESULT_UNSUPPORTED_SDK: + return STATUS_WIFI_CONNECT_UNSUPPORTED; + default: + return CommonStatusCodes.INTERNAL_ERROR; + } + } catch (IOException e) { + return CommonStatusCodes.INTERNAL_ERROR; + } + } + + public void onDataItemsChanged(List records) { + if (!isWatch || wifiManager == null) return; + for (DataItemRecord record : records) { + if (!GMS_PACKAGE.equals(record.packageName) || !GMS_SIGNATURE.equals(record.signatureDigest)) { + continue; + } + String path = record.dataItem.path; + if (PATH_WIFI_SYNC_PROTO.equals(path)) { + sawProtoItem.set(true); + } else if (!PATH_SYNC_WIFI_CREDENTIALS.equals(path) || sawProtoItem.get()) { + continue; + } + if (record.deleted) { + Log.d(TAG, "Wifi Credentials data item was deleted."); + continue; + } + if (setupComplete.get()) { + handler.sendMessage(handler.obtainMessage(MSG_DATA_ITEM, record)); + return; + } + Log.d(TAG, "Received wifi creds but setup has not completed, delaying until completion"); + } + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + private void performInitialSync() { + if (wifiManager == null) return; + if (!knownNetworksLoaded.get()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + for (WifiConfiguration config : getNetworks(false)) { + knownNetworks.put(Hidden.key(config), toInfo(config)); + } + } + knownNetworksLoaded.set(true); + } + DataItemRecord proto = queryDataItem(PATH_WIFI_SYNC_PROTO); + if (proto != null) { + sawProtoItem.set(true); + considerDataItem(proto); + } else if (!sawProtoItem.get()) { + considerDataItem(queryDataItem(PATH_SYNC_WIFI_CREDENTIALS)); + } + initialSyncDone = true; + } + + @Nullable + private DataItemRecord queryDataItem(String path) { + Cursor cursor = wearable.getNodeDatabase().getDataItemsByHostAndPath(GMS_PACKAGE, GMS_SIGNATURE, null, path); + if (cursor == null) return null; + try { + return cursor.moveToFirst() ? DataItemRecord.fromCursor(cursor) : null; + } finally { + cursor.close(); + } + } + + private void considerDataItem(@Nullable DataItemRecord record) { + if (record == null) { + Log.d(TAG, "Wifi sync skipped - no data item present."); + return; + } + if (prefs.getLong(PREF_LAST_SYNC_WRITTEN, -1L) >= record.lastModified) { + Log.d(TAG, "Wifi sync skipped - data item has not changed since last sync."); + return; + } + synchronized (pendingLock) { + pendingRecord = record; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && syncNetworkCallback == null && connectivityManager != null) { + NetworkRequest request = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(); + syncNetworkCallback = new ConnectivityManager.NetworkCallback(); + connectivityManager.requestNetwork(request, syncNetworkCallback); + } + if (wifiManager != null && wifiManager.isWifiEnabled()) { + Log.d(TAG, "Syncing credentials from handler."); + applyPendingDataItem("SyncHandler"); + } + } + } + + private void applyPendingDataItem(String reason) { + synchronized (pendingLock) { + DataItemRecord record = pendingRecord; + if (record == null || wifiManager == null) return; + Log.d(TAG, "Syncing credentials from " + reason); + + String path = record.dataItem.path; + byte[] data = record.dataItem.data; + if (data == null) { + Log.w(TAG, "Wifi data item has no payload"); + return; + } + if (PATH_WIFI_SYNC_PROTO.equals(path)) { + try { + WifiConfigurationList list = WifiConfigurationList.ADAPTER.decode(data); + Log.w(TAG, "Adding " + list.networks.size() + " networks from proto."); + int added = 0; + for (WifiConfigurationInfo info : list.networks) { + if (applyConfiguration(info, false)) added++; + } + if (added > 0) wifiManager.saveConfiguration(); + } catch (IOException e) { + Log.w(TAG, "Received invalid Wifi Sync proto", e); + return; + } + } else if (PATH_SYNC_WIFI_CREDENTIALS.equals(path)) { + if (!applyLegacy(DataMap.fromByteArray(data))) return; + } else { + Log.w(TAG, "Sync initialized with a non-wifi data item"); + return; + } + + unregisterCallbackSafely(syncNetworkCallback); + syncNetworkCallback = null; + prefs.edit().putLong(PREF_LAST_SYNC_WRITTEN, record.lastModified).commit(); + pendingRecord = null; + } + } + + private boolean applyLegacy(DataMap map) { + Log.d(TAG, "Credential source is from: " + map.getInt("source")); + ArrayList list = map.getDataMapArrayList("list"); + if (list == null) { + Log.w(TAG, "Received an empty wifi credentials data item."); + return false; + } + Log.d(TAG, "Adding " + list.size() + " networks."); + for (DataMap entry : list) { + String ssid = entry.getString("ssid"); + int keyMgmt = entry.getInt("key_mgmt"); + String key = entry.getString("key"); + if (ssid == null || keyMgmt == LEGACY_EAP) continue; + boolean hidden = entry.getInt("hiddenSsid") != 0; + + WifiConfiguration config = new WifiConfiguration(); + config.SSID = quote(ssid); + config.hiddenSSID = hidden; + if (keyMgmt == LEGACY_OPEN) { + config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); + } else { + config.priority = 1; + config.status = WifiConfiguration.Status.ENABLED; + if (keyMgmt == LEGACY_WEP) { + configureWep(config, key); + } else if (keyMgmt == LEGACY_PSK) { + configureWpaPsk(config, key); + } else { + Log.w(TAG, "Unrecognized key management scheme: " + keyMgmt); + continue; + } + } + enableNetwork(addNetwork(config), false); + } + Log.d(TAG, "Saving configurations to disk..."); + wifiManager.saveConfiguration(); + return true; + } + + public boolean onRpcRequest(int requestId, @Nullable String path, @Nullable byte[] data, + String sourceNodeId, boolean requiresResponse) { + if (!isWatch || !PATH_WIFI_CONNECT_IMMEDIATE.equals(path)) return false; + Log.i(TAG, "Received immediate wifi connection request."); + ConnectRequest pending = new ConnectRequest(requiresResponse, requestId, sourceNodeId, null); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Log.i(TAG, "SDK version on this device is too low (" + Build.VERSION.SDK_INT + + ") for immediate wifi connect; declining."); + reply(pending, RESULT_UNSUPPORTED_SDK); + return true; + } + try { + WifiConnectRequest request = WifiConnectRequest.ADAPTER.decode(data != null ? data : new byte[0]); + handler.sendMessage(handler.obtainMessage(MSG_CONNECT, + new ConnectRequest(requiresResponse, requestId, sourceNodeId, request))); + } catch (IOException e) { + Log.w(TAG, "Received invalid Wifi Sync proto", e); + reply(pending, RESULT_INVALID_REQUEST); + } + return true; + } + + private void connectImmediately(ConnectRequest connect) { + if (wifiManager == null || connectivityManager == null) { + reply(connect, RESULT_NETWORK_UNAVAILABLE); + return; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + reply(connect, RESULT_UNSUPPORTED_SDK); + return; + } + activeConnect = connect; + unregisterCallbackSafely(connectCallback); + connectCallback = null; + + wifiManager.setWifiEnabled(true); + NetworkRequest request = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(); + connectCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + handler.sendEmptyMessage(MSG_CONNECT_AVAILABLE); + } + + @Override + public void onUnavailable() { + handler.sendEmptyMessage(MSG_CONNECT_UNAVAILABLE); + } + }; + + long timeoutMs = durationToMillis(connect.request.timeout); + if (timeoutMs <= 0) timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS; + connectivityManager.requestNetwork(request, connectCallback, (int) Math.min(timeoutMs, Integer.MAX_VALUE)); + + WifiConfigurationInfo info = connect.request.config != null + ? connect.request.config : new WifiConfigurationInfo.Builder().build(); + applyConfiguration(info, true); + } + + private void finishConnect(int result) { + unregisterCallbackSafely(connectCallback); + connectCallback = null; + reply(activeConnect, result); + activeConnect = null; + } + + private void reply(@Nullable ConnectRequest connect, int result) { + if (connect == null || !connect.requiresResponse) return; + byte[] payload = new WifiConnectResponse.Builder().result(result).build().encode(); + wearable.getRpcTransport().sendResponse(GMS_PACKAGE, GMS_SIGNATURE, connect.sourceNodeId, + PATH_WIFI_CONNECT_IMMEDIATE, payload, connect.requestId); + } + + static WifiConfigurationInfo toInfo(WifiConfiguration config) { + WifiConfigurationInfo.Builder b = new WifiConfigurationInfo.Builder() + .ssid(unquote(config.SSID)) + .hiddenSsid(config.hiddenSSID); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Boolean autoJoin = Hidden.getBoolean(config, "allowAutojoin"); + Integer macRandomization = Hidden.getInt(config, "macRandomizationSetting"); + if (autoJoin != null) b.allowAutoJoin(autoJoin); + if (macRandomization != null) b.macRandomizationSetting(macRandomization); + } + if (config.allowedKeyManagement.get(SECURITY_WPA_PSK)) { + b.securityType(WifiConfigurationInfo.WifiSecurityType.fromValue(SECURITY_WPA_PSK)).preSharedKey(unquote(config.preSharedKey)); + } else if (config.allowedKeyManagement.get(SECURITY_WPA_EAP) + || config.allowedKeyManagement.get(WifiConfiguration.KeyMgmt.IEEE8021X)) { + b.securityType(WifiConfigurationInfo.WifiSecurityType.fromValue(SECURITY_WPA_EAP)); + } else if (config.allowedKeyManagement.get(SECURITY_SAE)) { + b.securityType(WifiConfigurationInfo.WifiSecurityType.fromValue(SECURITY_SAE)).preSharedKey(unquote(config.preSharedKey)); + } else if (config.allowedKeyManagement.get(SECURITY_OWE)) { + b.securityType(WifiConfigurationInfo.WifiSecurityType.fromValue(SECURITY_OWE)); + } else { + b.securityType(WifiConfigurationInfo.WifiSecurityType.fromValue(SECURITY_NONE)); + if (config.wepKeys != null && config.wepKeys[0] != null) b.wepKey(unquote(config.wepKeys[0])); + } + return b.build(); + } + + @Nullable + private static WifiConfiguration toConfiguration(WifiConfigurationInfo info) { + WifiConfiguration config = new WifiConfiguration(); + config.SSID = quote(info.ssid); + config.hiddenSSID = Boolean.TRUE.equals(info.hiddenSsid); + config.status = WifiConfiguration.Status.ENABLED; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (info.allowAutoJoin != null) Hidden.setBoolean(config, "allowAutojoin", info.allowAutoJoin); + if (info.macRandomizationSetting != null) { + Hidden.setInt(config, "macRandomizationSetting", info.macRandomizationSetting); + } + } + + int type = info.securityType != null ? info.securityType.getValue() : SECURITY_NONE; + if (type == SECURITY_NONE) { + config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); + if (info.wepKey != null) { + configureWep(config, info.wepKey); + } else { + config.priority = 0; + } + } else if (type == SECURITY_WPA_PSK) { + configureWpaPsk(config, info.preSharedKey); + } else if (type == SECURITY_SAE || type == SECURITY_OWE) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Log.w(TAG, "Current android platform is not supporting WPA3. Network skipped: " + info.ssid); + return null; + } + config.allowedProtocols.set(WifiConfiguration.Protocol.RSN); + config.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.CCMP); + config.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.GCMP_256); + config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.CCMP); + config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.GCMP_256); + Hidden.setBoolean(config, "requirePmf", true); + if (type == SECURITY_OWE) { + config.allowedKeyManagement.set(SECURITY_OWE); + } else { + config.allowedKeyManagement.set(SECURITY_SAE); + setPassphrase(config, info.preSharedKey); + } + } else { + Log.e(TAG, "Security type " + type + " of " + info.ssid + " is unsupported; skipping."); + return null; + } + return config; + } + + private static void configureWep(WifiConfiguration config, @Nullable String key) { + config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); + config.allowedProtocols.set(WifiConfiguration.Protocol.RSN); + config.allowedProtocols.set(WifiConfiguration.Protocol.WPA); + config.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN); + config.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.SHARED); + config.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.CCMP); + config.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.TKIP); + config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP40); + config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP104); + config.wepKeys = new String[4]; + int length = key == null ? 0 : key.length(); + if (length > 0) { + boolean hex = (length == 10 || length == 26 || length == 58) && key.matches("[0-9A-Fa-f]*"); + config.wepKeys[0] = hex ? key : quote(key); + config.wepTxKeyIndex = 0; + } + } + + private static void configureWpaPsk(WifiConfiguration config, @Nullable String key) { + config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK); + config.allowedProtocols.set(WifiConfiguration.Protocol.RSN); + config.allowedProtocols.set(WifiConfiguration.Protocol.WPA); + config.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.CCMP); + config.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.TKIP); + config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP40); + config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP104); + config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.TKIP); + config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.CCMP); + setPassphrase(config, key); + } + + private static void setPassphrase(WifiConfiguration config, @Nullable String key) { + if (key == null || key.isEmpty()) return; + + config.preSharedKey = key.matches("[0-9A-Fa-f]{64}") ? key : quote(key); + } + + private boolean applyConfiguration(WifiConfigurationInfo info, boolean immediate) { + WifiConfiguration config = toConfiguration(info); + if (config == null) return false; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || immediate) { + config.networkId = addNetwork(config); + enableNetwork(config.networkId, immediate); + return true; + } + + String key = Hidden.key(config); + WifiConfigurationInfo known = knownNetworks.get(key); + if (known == null) { + config.networkId = addNetwork(config); + enableNetwork(config.networkId, false); + remember(config, key, info); + return true; + } + if (sameNetwork(known, info)) { + return true; + } + + config.networkId = addNetwork(config); + remember(config, key, info); + return true; + } + + private static boolean sameNetwork(WifiConfigurationInfo a, WifiConfigurationInfo b) { + return Objects.equals(a.ssid, b.ssid) + && Boolean.TRUE.equals(a.hiddenSsid) == Boolean.TRUE.equals(b.hiddenSsid) + && Objects.equals(a.allowAutoJoin, b.allowAutoJoin) + && Objects.equals(a.macRandomizationSetting, b.macRandomizationSetting) + && Objects.equals(a.securityType, b.securityType) + && Objects.equals(a.preSharedKey, b.preSharedKey) + && Objects.equals(a.wepKey, b.wepKey); + } + + private void remember(WifiConfiguration config, String key, WifiConfigurationInfo info) { + if (config.networkId == -1) { + Log.w(TAG, "Network id is invalid. Not adding to store."); + return; + } + knownNetworks.put(key, info); + Log.d(TAG, "Network <" + config.SSID + "> added to store."); + } + + private int addNetwork(WifiConfiguration config) { + int id = wifiManager.addNetwork(config); + if (id == -1) { + Log.w(TAG, "Add network <" + config.SSID + "> failed."); + } else { + Log.i(TAG, "Network <" + config.SSID + "> added."); + } + return id; + } + + private void enableNetwork(int networkId, boolean disableOthers) { + if (networkId == -1) { + Log.w(TAG, "Network id is invalid. Enable network failed."); + return; + } + wifiManager.enableNetwork(networkId, disableOthers); + Log.i(TAG, "Network with id <" + networkId + "> enabled."); + } + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + private List getNetworks(boolean enableWifiTemporarily) { + if (wifiManager == null) return Collections.emptyList(); + boolean enabledByUs = false; + if (enableWifiTemporarily && !wifiManager.isWifiEnabled() && !isAirplaneModeOn()) { + enabledByUs = true; + wifiManager.setWifiEnabled(true); + } + List networks = Hidden.privilegedNetworks(wifiManager); + if (enabledByUs && !isAirplaneModeOn()) { + wifiManager.setWifiEnabled(false); + } + return networks; + } + + private boolean isAirplaneModeOn() { + return Settings.Global.getInt(context.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0) == 1; + } + + static String quote(@Nullable String s) { + if (TextUtils.isEmpty(s)) return ""; + return (s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"') ? s : "\"" + s + "\""; + } + + static String unquote(@Nullable String s) { + if (TextUtils.isEmpty(s)) return ""; + int length = s.length(); + if (length <= 1 || s.charAt(0) != '"') return s; + return s.charAt(length - 1) == '"' ? s.substring(1, length - 1) : s; + } + + private static ProtoDuration toDuration(long millis) { + return new ProtoDuration.Builder() + .seconds(millis / 1000L) + .nanos((int) ((millis % 1000L) * 1_000_000L)) + .build(); + } + + private static long durationToMillis(@Nullable ProtoDuration duration) { + if (duration == null) return 0; + long seconds = duration.seconds != null ? duration.seconds : 0; + int nanos = duration.nanos != null ? duration.nanos : 0; + return seconds * 1000L + nanos / 1_000_000L; + } + + private static final class Hidden { + + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + static List privilegedNetworks(@Nullable WifiManager manager) { + if (manager == null) return Collections.emptyList(); + try { + Method method = WifiManager.class.getMethod("getPrivilegedConfiguredNetworks"); + Object result = method.invoke(manager); + if (result instanceof List) return (List) result; + } catch (ReflectiveOperationException | RuntimeException e) { + Log.d(TAG, "getPrivilegedConfiguredNetworks unavailable, falling back: " + e); + } + try { + List networks = manager.getConfiguredNetworks(); + if (networks != null) return networks; + } catch (SecurityException e) { + Log.w(TAG, "getConfiguredNetworks permission denied", e); + } catch (RuntimeException e) { + Log.w(TAG, "getConfiguredNetworks failed", e); + } + return Collections.emptyList(); + } + + static String key(WifiConfiguration config) { + try { + Object key = WifiConfiguration.class.getMethod("getKey").invoke(config); + if (key instanceof String) return (String) key; + } catch (ReflectiveOperationException | RuntimeException ignored) { + } + return config.SSID + "|" + config.allowedKeyManagement; + } + + static boolean isEphemeral(WifiConfiguration config) { + try { + Object result = WifiConfiguration.class.getMethod("isEphemeral").invoke(config); + return Boolean.TRUE.equals(result); + } catch (ReflectiveOperationException | RuntimeException e) { + return false; + } + } + + @Nullable + static Boolean getBoolean(Object target, String field) { + try { + Field f = target.getClass().getField(field); + return f.getBoolean(target); + } catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + + @Nullable + static Integer getInt(Object target, String field) { + try { + Field f = target.getClass().getField(field); + return f.getInt(target); + } catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + + static void setBoolean(Object target, String field, boolean value) { + try { + target.getClass().getField(field).setBoolean(target, value); + } catch (ReflectiveOperationException | RuntimeException e) { + Log.d(TAG, "Could not set " + field + ": " + e); + } + } + + static void setInt(Object target, String field, int value) { + try { + target.getClass().getField(field).setInt(target, value); + } catch (ReflectiveOperationException | RuntimeException e) { + Log.d(TAG, "Could not set " + field + ": " + e); + } + } + } +} \ No newline at end of file diff --git a/play-services-wearable/core/src/main/proto/wearable.proto b/play-services-wearable/core/src/main/proto/wearable.proto new file mode 100644 index 0000000000..db3795c36d --- /dev/null +++ b/play-services-wearable/core/src/main/proto/wearable.proto @@ -0,0 +1,357 @@ +/* + * SPDX-FileCopyrightText: 2015, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +option java_package = "org.microg.gms.wearable.proto"; +option java_outer_classname = "WearableProto"; + +message AccountMatchingMessage { + enum Type { + //UNRECOGNIZED = -1; + UNSPECIFIED = 0; + GET_ACCOUNTS = 1; + ACCOUNTS_RESPONSE = 2; + REMOVE_ACCOUNTS = 3; + REMOVE_ACCOUNTS_CONFIRM = 4; + CANCEL = 5; + REMOTE_ERROR = 6; + } + + optional int32 type = 1; + repeated AccountMatchingEntry entries = 2; + optional string exceptionMessage = 3; +} + +message AccountMatchingEntry { + optional string nameMaybe = 1; // Not sure here + optional string id = 2; +} + +message AckAsset { + optional string digest = 1; +} + +message AppKey { + optional string packageName = 1; + optional string signatureDigest = 2; +} + +message AppKeys { + repeated AppKey appKeys = 1; +} + +message Asset { + // cannot find what other fields is + // maybe deprecated and + // not used anymore in new google gms + optional string digest = 4; +} + +message AssetEntry { + optional string key = 1; + optional Asset value = 2; + optional int32 unknown3 = 3; +} + +message CapabilityFilters { + repeated CapabilityFilterEntry entries = 1; +} + +message CapabilityFilterEntry { + optional string nameMaybe = 1; +} + +message ChannelControlRequest { + enum Type { + CHANNEL_CONTROL_OPEN = 1; + CHANNEL_CONTROL_OPEN_ACK = 2; + CHANNEL_CONTROL_CLOSE = 3; + } + + optional int32 type = 1; + optional sfixed64 channelId = 2; + optional bool fromChannelOperator = 3; + optional string packageName = 4; + optional string signatureDigest = 5; + optional string path = 6; + optional int32 closeErrorCode = 7; + optional bool isReliable = 8; +} + +message ChannelDataAckRequest { + optional ChannelDataHeader header = 1; + optional bool finalMessage = 2; +} + +message ChannelDataHeader { + optional sfixed64 channelId = 1; + optional bool fromChannelOperator = 2; + optional int64 requestId = 3; +} + +message ChannelDataRequest { + optional ChannelDataHeader header = 1; + optional bytes payload = 2; + optional bool finalMessage = 3; +} + +message ChannelRequest { + optional ChannelControlRequest channelControlRequest = 2; + optional ChannelDataRequest channelDataRequest = 3; + optional ChannelDataAckRequest channelDataAckRequest = 4; + optional int32 version = 6; + optional int32 origin = 7; +} + +message Connect { + optional string id = 1; + optional string name = 2; + optional int64 peerAndroidId = 3; + optional int32 unknown4 = 4; // Always has value 3 + optional int32 peerVersion = 5; + optional int32 peerMinimumVersion = 6; + optional string networkId = 7; + optional string packageName = 8; + optional bool migrating = 9; + optional string migratingFromNodeId = 10; + optional int32 androidSdkVersion = 11; +} + +message ConnectionRestrictions { + repeated ConnectionRestrictionEntry entries = 1; +} + +message ConnectionRestrictionEntry { + optional string packageName = 1; + optional DataItemFilters dataItemFilters = 2; + optional CapabilityFilters capabilityFilters = 3; +} + +message ControlMessage { + enum Type { + UNKNOWN = 1; + TERMINATE_ASSOCIATION = 2; + SUSPEND_SYNC = 3; + RESUME_SYNC = 4; + MIGRATION_FAILED = 5; + ACCOUNT_MATCHING = 6; + MIGRATION_CANCELLED = 7; + } + + optional int32 type = 1; + optional AccountMatchingMessage accountMatching = 2; +} + +message DataItemFilters { + repeated DataItemFilterEntry entries = 1; +} + +message DataItemFilterEntry { + optional int32 filterType = 1; + optional string uri = 2; +} + +message EncryptionHandshake { + optional bytes cryptoNegotiationResponse = 1; // Unsure + optional bytes resumeBytes = 2; // Unsure + optional bool unknown3 = 3; + optional int32 unknown4 = 4; +} + +message FetchAsset { + optional string packageName = 1; + optional string assetName = 2; + optional bool permission = 3; + optional string signatureDigest = 4; +} + +message FilePiece { + optional string fileName = 1; + optional bool finalPiece = 2; + optional bytes piece = 3; + optional string digest = 4; +} + +message Heartbeat { + +} + +// Ios related only assumptions and not consider as true +// as i think is related to apple watch and apple companion +message IosMultiAppAuthHandshake { + optional bytes data = 1; +} + +message IosMultiAppAuthAttestation { + optional string key_id = 1; + optional string client_data = 2; + optional bytes attestation = 3; + optional bytes assertion = 4; +} + +message IosMultiAppAuthResult { + enum Status { + UNKNOWN = 0; + SUCCESS = 1; + FAILURE = 2; + CANCELLED = 3; + } + optional Status status = 1; +} + +message IosMultiAppAuth { + oneof payload { + IosMultiAppAuthHandshake handshake = 1; + IosMultiAppAuthAttestation attestation = 2; + IosMultiAppAuthResult result = 3; + } +} + +message MessagePiece { + optional bytes data = 1; + optional string digest = 2; + optional int32 thisPiece = 3; + optional int32 totalPieces = 4; + optional int32 queueId = 5; +} + +message Request { + optional int32 requestId = 1; + optional string packageName = 2; + optional string signatureDigest = 3; + optional string targetNodeId = 4; + optional int32 unknown5 = 5; // always has to be 0 + optional string path = 6; + optional bytes rawData = 7; + optional string sourceNodeId = 8; + optional ChannelRequest request = 9; + optional int32 generation = 10; + optional bool requiresResponse = 11; + optional int32 senderRequestId = 12; + optional int32 priority = 13; +} + +message RootMessage { + optional SetAsset setAsset = 4; + optional AckAsset ackAsset = 5; + optional FetchAsset fetchAsset = 6; + optional Connect connect = 7; + optional SyncStart syncStart = 8; + optional SetDataItem setDataItem = 9; + optional Request rpcRequest = 10; + optional Heartbeat heartbeat = 11; + optional FilePiece filePiece = 12; + optional bool hasAsset = 13; + optional Request channelRequest = 16; + optional EncryptionHandshake e2eHandshake = 17; + optional Request rpcServiceRequest = 18; + optional IosMultiAppAuth iosMultiAppAuth = 19; + optional ControlMessage controlMessage = 20; +} + +message SetAsset { + optional string digest = 1; + optional bytes data = 2; + optional AppKeys appkeys = 3; +} + +message SetDataItem { + optional string packageName = 1; + optional string uri = 2; + repeated string unknown3 = 3; + optional bytes data = 4; + optional int64 seqId = 5; + optional bool deleted = 6; + optional string source = 7; + repeated AssetEntry assets = 8; + optional string signatureDigest = 9; + optional int64 lastModified = 10; +} + +message SyncStart { + optional int64 receivedSeqId = 1; + repeated SyncTableEntry syncTable = 2; + optional int32 version = 3; + optional ConnectionRestrictions restrictions = 4; +} + +message SyncTableEntry { + optional string key = 1; + optional int64 value = 2; +} + +// for backup/restore parcelables +message BackupBoolResponse { + optional bool value = 1; +} + +message DataSyncTrackingMessage { + optional string trackerId = 1; + optional int64 sequenceId = 2; +} + +message PrivacySettings { + optional bool loggingConsent = 1; + optional bool cloudSyncConsent = 2; + optional bool locationConsent = 3; + repeated AccountConsentRecord accountConsents = 5; + optional string nodeId = 6; + optional ProtoDuration lastUpdateRequested = 7; +} + +message AccountConsentRecord { + optional string accountName = 1; + optional bool consentGranted = 2; +} + +message EnableBackupRequest { + optional string accountName = 1; + optional bool inSetup = 3; + optional bool flag = 4; // set from a GMS server flag +} + +message EnableBackupSkippedRequest { + optional int32 fragmentType = 1; + optional bool flag = 2; +} + +message BackupErrorResponse { + optional int32 errorCode = 1; +} + +message ProtoDuration { + optional int64 seconds = 1; + optional int32 nanos = 2; +} + +message WifiConfigurationInfo { + enum WifiSecurityType { + SECURITY_NONE = 0; + SECURITY_WPA_PSK = 1; + SECURITY_WPA_EAP = 2; + SECURITY_SAE = 8; + SECURITY_OWE = 9; + } + optional string ssid = 1; + optional bool hiddenSsid = 2; + optional WifiSecurityType securityType = 3; + optional string preSharedKey = 4; + optional string wepKey = 5; + optional bool allowAutoJoin = 6; + optional int32 macRandomizationSetting = 7; +} + +message WifiConfigurationList { + repeated WifiConfigurationInfo networks = 1; +} + +message WifiConnectRequest { + optional WifiConfigurationInfo config = 1; + optional ProtoDuration timeout = 2; +} + +message WifiConnectResponse { + // 1 success, 2 invalid request, 3 network unavailable/timeout, 4 SDK too old + optional int32 result = 1; +} diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/AppRecommendationsRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/AppRecommendationsRequest.aidl new file mode 100644 index 0000000000..51e71505ec --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/AppRecommendationsRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable; + +parcelable AppRecommendationsRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/AppTheme.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/AppTheme.aidl new file mode 100644 index 0000000000..efe34fdf71 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/AppTheme.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable; + +parcelable AppTheme; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/ConnectionDelayConfig.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/ConnectionDelayConfig.aidl new file mode 100644 index 0000000000..e514bc0cc5 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/ConnectionDelayConfig.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable; + +parcelable ConnectionDelayConfig; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/MessageOptions.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/MessageOptions.aidl new file mode 100644 index 0000000000..ea912c5f67 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/MessageOptions.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable; + +parcelable MessageOptions; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AcceptTermsRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AcceptTermsRequest.aidl new file mode 100644 index 0000000000..97bdcb3939 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AcceptTermsRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable AcceptTermsRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AddAccountToConsentRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AddAccountToConsentRequest.aidl new file mode 100644 index 0000000000..e85686d61b --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AddAccountToConsentRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable AddAccountToConsentRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AddSupervisedAccountRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AddSupervisedAccountRequest.aidl new file mode 100644 index 0000000000..ad90167b87 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AddSupervisedAccountRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable AddSupervisedAccountRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AppRecommendationsResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AppRecommendationsResponse.aidl new file mode 100644 index 0000000000..70bfbfd7a4 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/AppRecommendationsResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable AppRecommendationsResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/BooleanResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/BooleanResponse.aidl new file mode 100644 index 0000000000..9d4f7bc77a --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/BooleanResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable BooleanResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/ConsentResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/ConsentResponse.aidl new file mode 100644 index 0000000000..8f555d0c30 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/ConsentResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable ConsentResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/ConsentStatusRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/ConsentStatusRequest.aidl new file mode 100644 index 0000000000..1ebaad065e --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/ConsentStatusRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable ConsentStatusRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetAppThemeResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetAppThemeResponse.aidl new file mode 100644 index 0000000000..c674117ae1 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetAppThemeResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetAppThemeResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetBackupSettingsSupportedResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetBackupSettingsSupportedResponse.aidl new file mode 100644 index 0000000000..870ef17d16 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetBackupSettingsSupportedResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetBackupSettingsSupportedResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetCompanionPackageForNodeResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetCompanionPackageForNodeResponse.aidl new file mode 100644 index 0000000000..50f6a8f2e5 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetCompanionPackageForNodeResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetCompanionPackageForNodeResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetEapIdResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetEapIdResponse.aidl new file mode 100644 index 0000000000..159e162dd4 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetEapIdResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetEapIdResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetFastpairAccountKeyByAccountResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetFastpairAccountKeyByAccountResponse.aidl new file mode 100644 index 0000000000..1d63772bb4 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetFastpairAccountKeyByAccountResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetFastpairAccountKeyByAccountResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetFastpairAccountKeysResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetFastpairAccountKeysResponse.aidl new file mode 100644 index 0000000000..1c8aa904d9 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetFastpairAccountKeysResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetFastpairAccountKeysResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetNodeIdResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetNodeIdResponse.aidl new file mode 100644 index 0000000000..65781b09a2 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetNodeIdResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetNodeIdResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreStateRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreStateRequest.aidl new file mode 100644 index 0000000000..ae131822cf --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreStateRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetRestoreStateRequest; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreStateResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreStateResponse.aidl new file mode 100644 index 0000000000..12482e13e9 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreStateResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetRestoreStateResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreSupportedResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreSupportedResponse.aidl new file mode 100644 index 0000000000..84aeaefe3a --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetRestoreSupportedResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetRestoreSupportedResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetTermsResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetTermsResponse.aidl new file mode 100644 index 0000000000..8462b8fe37 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/GetTermsResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable GetTermsResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IChannelStreamCallbacks.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IChannelStreamCallbacks.aidl index 1f08e37338..183fafdf4a 100644 --- a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IChannelStreamCallbacks.aidl +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IChannelStreamCallbacks.aidl @@ -1,4 +1,5 @@ package com.google.android.gms.wearable.internal; interface IChannelStreamCallbacks { + void onChannelClosed(int closeReason, int appSpecificErrorCode) = 2; } diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IRpcResponseCallback.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IRpcResponseCallback.aidl new file mode 100644 index 0000000000..73a66abba9 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IRpcResponseCallback.aidl @@ -0,0 +1,5 @@ +package com.google.android.gms.wearable.internal; + +interface IRpcResponseCallback { + void onResponse(boolean success, in byte[] data) = 0; +} \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableCallbacks.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableCallbacks.aidl index ffa91cb9e3..87b9003d4e 100644 --- a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableCallbacks.aidl +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableCallbacks.aidl @@ -26,6 +26,22 @@ import com.google.android.gms.wearable.internal.RemoveLocalCapabilityResponse; import com.google.android.gms.wearable.internal.SendMessageResponse; import com.google.android.gms.wearable.internal.StorageInfoResponse; +import com.google.android.gms.wearable.internal.ConsentResponse; +import com.google.android.gms.wearable.internal.GetTermsResponse; +import com.google.android.gms.wearable.internal.GetFastpairAccountKeyByAccountResponse; +import com.google.android.gms.wearable.internal.GetFastpairAccountKeysResponse; +import com.google.android.gms.wearable.internal.GetRestoreStateResponse; +import com.google.android.gms.wearable.internal.BooleanResponse; +import com.google.android.gms.wearable.internal.GetCompanionPackageForNodeResponse; +import com.google.android.gms.wearable.internal.RpcResponse; +import com.google.android.gms.wearable.internal.GetEapIdResponse; +import com.google.android.gms.wearable.internal.PerformEapAkaResponse; +import com.google.android.gms.wearable.internal.GetNodeIdResponse; +import com.google.android.gms.wearable.internal.GetBackupSettingsSupportedResponse; +import com.google.android.gms.wearable.internal.GetAppThemeResponse; +import com.google.android.gms.wearable.internal.AppRecommendationsResponse; +import com.google.android.gms.wearable.internal.GetRestoreSupportedResponse; + interface IWearableCallbacks { // Config void onGetConfigResponse(in GetConfigResponse response) = 1; @@ -49,6 +65,7 @@ interface IWearableCallbacks { // Channels void onOpenChannelResponse(in OpenChannelResponse response) = 13; void onCloseChannelResponse(in CloseChannelResponse response) = 14; + void onCloseChannelResponse2(in CloseChannelResponse response) = 15; // found two entries in google gms void onGetChannelInputStreamResponse(in GetChannelInputStreamResponse response) = 16; void onGetChannelOutputStreamResponse(in GetChannelOutputStreamResponse response) = 17; void onChannelReceiveFileResponse(in ChannelReceiveFileResponse response) = 18; @@ -62,4 +79,27 @@ interface IWearableCallbacks { void onGetAllCapabilitiesResponse(in GetAllCapabilitiesResponse response) = 22; void onAddLocalCapabilityResponse(in AddLocalCapabilityResponse response) = 25; void onRemoveLocalCapabilityResponse(in RemoveLocalCapabilityResponse response) = 26; + + // Terms of service + void onGetTermsResponse(in GetTermsResponse response) = 48; + void onConsentResponse(in ConsentResponse response) = 37; + + // Fastpair + void onGetFastpairAccountKeyByAccountResponse(in GetFastpairAccountKeyByAccountResponse response) = 49; + void onGetFastpairAccountKeysResponse(in GetFastpairAccountKeysResponse response) = 47; + + // Uncategorized + void onGetRestoreStateResponse(in GetRestoreStateResponse response) = 46; + void onBooleanResponse(in BooleanResponse response) = 45; + void onGetCompanionPackageForNodeResponse(in GetCompanionPackageForNodeResponse response) = 36; + void onRpcResponse(in RpcResponse response) = 33; + void onGetEapIdResponse(in GetEapIdResponse response) = 34; + void onPerformEapAkaResponse(in PerformEapAkaResponse response) = 35; + void onGetNodeIdResponse(in GetNodeIdResponse response) = 38; + void onAppRecommendationsResponse(in AppRecommendationsResponse response) = 39; + void onGetAppThemeResponse(in GetAppThemeResponse response) = 40; + void onGetBackupSettingsSupportedResponse(in GetBackupSettingsSupportedResponse response) = 41; + void onGetRestoreSupportedResponse(in GetRestoreSupportedResponse response) = 42; + } + diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableListener.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableListener.aidl index 521f1166f8..a8b6ab21ce 100644 --- a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableListener.aidl +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableListener.aidl @@ -7,6 +7,7 @@ import com.google.android.gms.wearable.internal.CapabilityInfoParcelable; import com.google.android.gms.wearable.internal.ChannelEventParcelable; import com.google.android.gms.wearable.internal.MessageEventParcelable; import com.google.android.gms.wearable.internal.NodeParcelable; +import com.google.android.gms.wearable.internal.IRpcResponseCallback; interface IWearableListener { void onDataChanged(in DataHolder data) = 0; @@ -18,4 +19,5 @@ interface IWearableListener { void onChannelEvent(in ChannelEventParcelable channelEvent) = 6; void onConnectedCapabilityChanged(in CapabilityInfoParcelable capabilityInfo) = 7; void onEntityUpdate(in AmsEntityUpdateParcelable update) = 8; + void onRequest(in MessageEventParcelable messageEvent, IRpcResponseCallback callback) = 12; } diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableService.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableService.aidl index 423dc8b3a9..0aaa4e7704 100644 --- a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableService.aidl +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableService.aidl @@ -10,14 +10,45 @@ import com.google.android.gms.wearable.internal.IChannelStreamCallbacks; import com.google.android.gms.wearable.internal.IWearableCallbacks; import com.google.android.gms.wearable.internal.IWearableService; +import com.google.android.gms.wearable.internal.PrivacyRecordOptinRequest; +import com.google.android.gms.wearable.internal.AddAccountToConsentRequest; + +import com.google.android.gms.wearable.internal.LogCounterRequest; +import com.google.android.gms.wearable.internal.LogEventRequest; +import com.google.android.gms.wearable.internal.LogTimerRequest; + + +import com.google.android.gms.wearable.internal.GetBackupSettingsSupportedResponse; +import com.google.android.gms.wearable.internal.GetRestoreStateRequest; +import com.google.android.gms.wearable.internal.GetRestoreStateResponse; +import com.google.android.gms.wearable.internal.GetRestoreSupportedResponse; +import com.google.android.gms.wearable.internal.SaveRestoreStateRequest; +import com.google.android.gms.wearable.internal.StartRestoreSessionRequest; + +import com.google.android.gms.wearable.MessageOptions; + +import android.accounts.Account; +import com.google.android.gms.wearable.AppTheme; +import com.google.android.gms.wearable.AppRecommendationsRequest; +import com.google.android.gms.wearable.ConnectionDelayConfig; +import com.google.android.gms.wearable.internal.AddSupervisedAccountRequest; +import com.google.android.gms.wearable.internal.RecordUntetheredSupervisedAccountTransferRequest; +import com.google.android.gms.wearable.internal.AcceptTermsRequest; +import com.google.android.gms.wearable.internal.RecordTermConsentRequest; +import com.google.android.gms.wearable.internal.ConsentStatusRequest; + interface IWearableService { // Configs void putConfig(IWearableCallbacks callbacks, in ConnectionConfiguration config) = 19; void deleteConfig(IWearableCallbacks callbacks, String name) = 20; void getConfigs(IWearableCallbacks callbacks) = 21; - void enableConfig(IWearableCallbacks callbacks, String name) = 22; + void enableConfig(IWearableCallbacks callbacks, String name) = 22; // aka enableConnection void disableConfig(IWearableCallbacks callbacks, String name) = 23; + void updateConnectionStrategy(IWearableCallbacks callbacks, String name, int strategy) = 71; + void getRelatedConfigs(IWearableCallbacks callbacks) = 72; + void updateConfig(IWearableCallbacks iWearableCallbacks, in ConnectionConfiguration config) = 73; + // DataItems void putData(IWearableCallbacks callbacks, in PutDataRequest request) = 5; void getDataItem(IWearableCallbacks callbacks, in Uri uri) = 6; @@ -28,9 +59,14 @@ interface IWearableService { void deleteDataItemsWithFilter(IWearableCallbacks callbacks, in Uri uri, int typeFilter) = 40; void sendMessage(IWearableCallbacks callbacks, String targetNodeId, String path, in byte[] data) = 11; + void sendRequest(IWearableCallbacks callbacks, String targetNodeId, String path, in byte[] data) = 57; + void sendMessageWithOptions(IWearableCallbacks callbacks, String targetNodeId, String path, in byte[] data, in MessageOptions options) = 58; + void sendRequestWithOptions(IWearableCallbacks callbacks, String targetNodeId, String path, in byte[] data, in MessageOptions options) = 59; + void getFdForAsset(IWearableCallbacks callbacks, in Asset asset) = 12; void getLocalNode(IWearableCallbacks callbacks) = 13; + void getNodeId(IWearableCallbacks callbacks, String address) = 66; void getConnectedNodes(IWearableCallbacks callbacks) = 14; // Capabilties @@ -64,6 +100,8 @@ interface IWearableService { void readChannelOutputFromFd(IWearableCallbacks callbacks, String s, in ParcelFileDescriptor fd, long l1, long l2) = 38; void syncWifiCredentials(IWearableCallbacks callbacks) = 36; + void syncWifiCredentialWithSsid(IWearableCallbacks callbacks, String ssid, String password) = 87; + void syncWifiCredentialForNode(IWearableCallbacks callbacks, String nodeId) = 88; // Cloud Sync void optInCloudSync(IWearableCallbacks callbacks, boolean enable) = 47; @@ -72,7 +110,65 @@ interface IWearableService { void getCloudSyncSetting(IWearableCallbacks callbacks) = 50; void getCloudSyncOptInStatus(IWearableCallbacks callbacks) = 51; - void sendRemoteCommand(IWearableCallbacks callbacks, byte b) = 52; + void sendAmsRemoteCommand(IWearableCallbacks callbacks, byte command) = 52; + + void privacyRecordOptinRequest(IWearableCallbacks callbacks, in PrivacyRecordOptinRequest request) = 70; + + void someBoolUnknown(IWearableCallbacks callbacks) = 84; // cannot figure out name + + void getCompanionPackageForNode(IWearableCallbacks callbacks, String nodeId) = 62; + + void setCloudSyncSettingByNode(IWearableCallbacks callbacks, String s, boolean b) = 74; + + void logCounter(IWearableCallbacks callbacks, in LogCounterRequest request) = 105; + void logEvent(IWearableCallbacks callbacks, in LogEventRequest request) = 106; + void logTimer(IWearableCallbacks callbacks, in LogTimerRequest request) = 107; + void clearLogs(IWearableCallbacks callbacks) = 108; // just assuming this is clearLogs + + // Backup / Restore + void getBackupSettingsSupported(IWearableCallbacks callbacks, String nodeId) = 78; + void getRestoreSupported(IWearableCallbacks callbacks) = 79; + void startRestoreSession(IWearableCallbacks callbacks, in StartRestoreSessionRequest request) = 81; + void saveRestoreState(IWearableCallbacks callbacks, in SaveRestoreStateRequest request) = 85; + void getRestoreState(IWearableCallbacks callbacks, in GetRestoreStateRequest request) = 86; + void getBackupEnabled(IWearableCallbacks callbacks, String nodeId) = 90; + + void dataSynchronizationProgressTracking(IWearableCallbacks callbacks, String peerNodeId) = 92; + + // eSIM + void getEapId(IWearableCallbacks callbacks, int subscriptionId) = 60; + void performEapAka(IWearableCallbacks callbacks, int subscriptionId, String s) = 61; + + // FastPair + void associateDeviceAndAccountWithFastPair(IWearableCallbacks callbacks, String s1, in Account account, String s2, String s3) = 63; + void getFastpairAccountKeys(IWearableCallbacks callbacks) = 91; + void getFastpairAccountKeyByAccount(IWearableCallbacks callbacks, in Account account) = 96; + + // Recommendations / theming + void getAppRecommendations(IWearableCallbacks callbacks, in AppRecommendationsRequest request) = 67; + void setThemeForApp(IWearableCallbacks callbacks, in AppTheme theme) = 76; + void getThemeForApp(IWearableCallbacks callbacks, String packageName) = 77; + + // Connection management + void retryConnection(IWearableCallbacks callbacks, String nodeId, boolean b) = 68; + void cancelMigration(IWearableCallbacks callbacks, in ConnectionConfiguration config) = 75; + void isNodeConnectionMetered(IWearableCallbacks callbacks, String nodeId) = 89; + void setConnectionDelayConfig(IWearableCallbacks callbacks, in ConnectionDelayConfig config) = 100; + void connectionDelayUnknown101(IWearableCallbacks callbacks, String s) = 101; + void clearConnectionDelayConfig(IWearableCallbacks callbacks, String nodeId) = 102; + + // Supervised accounts + void addSupervisedAccount(IWearableCallbacks callbacks, in AddSupervisedAccountRequest request) = 69; + void recordUntetheredSupervisedAccountTransfer(IWearableCallbacks callbacks, in RecordUntetheredSupervisedAccountTransferRequest request) = 104; + + // Consent / terms + void getConsentStatus(IWearableCallbacks callbacks) = 64; + void addAccountToConsent(IWearableCallbacks callbacks, in AddAccountToConsentRequest request) = 65; + void acceptTerms(IWearableCallbacks callbacks, in AcceptTermsRequest request) = 93; + void recordTermConsent(IWearableCallbacks callbacks, in RecordTermConsentRequest request) = 94; + void getTerms(IWearableCallbacks callbacks, int i) = 95; + void getConsentStatusForRequest(IWearableCallbacks callbacks, in ConsentStatusRequest request) = 103; + void recordSwaadlOptIn(IWearableCallbacks callbacks) = 83; // deprecated Connection void putConnection(IWearableCallbacks callbacks, in ConnectionConfiguration config) = 1; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogCounterRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogCounterRequest.aidl new file mode 100644 index 0000000000..709645b8bd --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogCounterRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable LogCounterRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogEventRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogEventRequest.aidl new file mode 100644 index 0000000000..e41a6e3c29 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogEventRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable LogEventRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogTimerRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogTimerRequest.aidl new file mode 100644 index 0000000000..7298d5fcbf --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/LogTimerRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable LogTimerRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/PerformEapAkaResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/PerformEapAkaResponse.aidl new file mode 100644 index 0000000000..52831b7cd8 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/PerformEapAkaResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable PerformEapAkaResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/PrivacyRecordOptinRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/PrivacyRecordOptinRequest.aidl new file mode 100644 index 0000000000..ecb0f37fe0 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/PrivacyRecordOptinRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable PrivacyRecordOptinRequest; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RecordTermConsentRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RecordTermConsentRequest.aidl new file mode 100644 index 0000000000..5e9d825226 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RecordTermConsentRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable RecordTermConsentRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RecordUntetheredSupervisedAccountTransferRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RecordUntetheredSupervisedAccountTransferRequest.aidl new file mode 100644 index 0000000000..1cc443ef01 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RecordUntetheredSupervisedAccountTransferRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable RecordUntetheredSupervisedAccountTransferRequest; \ No newline at end of file diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RpcResponse.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RpcResponse.aidl new file mode 100644 index 0000000000..2f47e9e3d3 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/RpcResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable RpcResponse; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/SaveRestoreStateRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/SaveRestoreStateRequest.aidl new file mode 100644 index 0000000000..314ec61df0 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/SaveRestoreStateRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable SaveRestoreStateRequest; diff --git a/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/StartRestoreSessionRequest.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/StartRestoreSessionRequest.aidl new file mode 100644 index 0000000000..eab5d140f9 --- /dev/null +++ b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/StartRestoreSessionRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.wearable.internal; + +parcelable StartRestoreSessionRequest; diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/AppRecommendationsRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/AppRecommendationsRequest.java new file mode 100644 index 0000000000..98cdbc3c0e --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/AppRecommendationsRequest.java @@ -0,0 +1,30 @@ +package com.google.android.gms.wearable; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class AppRecommendationsRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String androidId; + @SafeParceled(2) + public int searchFlags; + @SafeParceled(3) + public int limit; + + public AppRecommendationsRequest() { + } + + public AppRecommendationsRequest(String androidId, int searchFlags, int limit) { + this.androidId = androidId; + this.searchFlags = searchFlags; + this.limit = limit; + } + + @Override + public String toString() { + return "AppRecommendationsRequest{androidId='" + androidId + "', searchFlags=" + searchFlags + ", limit=" + limit + "}"; + } + + public static final Creator CREATOR = new AutoCreator<>(AppRecommendationsRequest.class); +} + diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/AppTheme.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/AppTheme.java new file mode 100644 index 0000000000..cc0f5dd551 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/AppTheme.java @@ -0,0 +1,50 @@ +package com.google.android.gms.wearable; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class AppTheme extends AutoSafeParcelable { + @SafeParceled(1) + public int colorTheme; + @SafeParceled(2) + public int dynamicColor; + @SafeParceled(3) + public int screenAlignment; + @SafeParceled(4) + public int screenItemsSize; + + public AppTheme() { + } + + public AppTheme(int colorTheme, int dynamicColor, int screenAlignment, int screenItemsSize) { + this.colorTheme = colorTheme; + this.dynamicColor = dynamicColor; + this.screenAlignment = screenAlignment; + this.screenItemsSize = screenItemsSize; + } + + public int getColorTheme() { + return colorTheme == 0 ? 1 : colorTheme; + } + + public int getDynamicColor() { + return dynamicColor == 0 ? 1 : dynamicColor; + } + + public int getScreenAlignment() { + return screenAlignment == 0 ? 1 : screenAlignment; + } + + public int getScreenItemsSize() { + return screenItemsSize == 0 ? 3 : screenItemsSize; + } + + @Override + public String toString() { + return "AppTheme {dynamicColor =" + dynamicColor + ", colorTheme =" + colorTheme + + ", screenAlignment =" + screenAlignment + ", screenItemsSize =" + screenItemsSize + "}"; + } + + public static final Creator CREATOR = new AutoCreator<>(AppTheme.class); +} + diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/Asset.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/Asset.java index c9e22edf1c..3f19ea60c9 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/Asset.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/Asset.java @@ -26,6 +26,7 @@ import org.microg.safeparcel.SafeParceled; import java.util.Arrays; +import java.util.Objects; /** * An asset is a binary blob shared between data items that is replicated across the wearable @@ -50,8 +51,7 @@ public class Asset extends AutoSafeParcelable { @SafeParceled(5) private Uri uri; - private Asset() { - } + private Asset() {} private Asset(byte[] data, String digest, ParcelFileDescriptor fd, Uri uri) { this.data = data; @@ -64,7 +64,10 @@ private Asset(byte[] data, String digest, ParcelFileDescriptor fd, Uri uri) { * Creates an Asset using a byte array. */ public static Asset createFromBytes(byte[] assetData) { - return null; + if (assetData == null) { + throw new IllegalArgumentException("Asset data cannot be null"); + } + return new Asset(assetData, null, null, null); } /** @@ -93,7 +96,10 @@ public static Asset createFromRef(String digest) { * Uri. */ public static Asset createFromUri(Uri uri) { - return null; + if (uri == null) { + throw new IllegalArgumentException("Asset uri cannot be null"); + } + return new Asset(null, null, null, uri); } /** @@ -118,23 +124,28 @@ public Uri getUri() { return uri; } + public byte[] getData() { + return data; + } + @Override public boolean equals(Object o) { if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (!(o instanceof Asset)) return false; +// if (o == null || getClass() != o.getClass()) return false; Asset asset = (Asset) o; if (!Arrays.equals(data, asset.data)) return false; - if (digest != null ? !digest.equals(asset.digest) : asset.digest != null) return false; - if (fd != null ? !fd.equals(asset.fd) : asset.fd != null) return false; - return !(uri != null ? !uri.equals(asset.uri) : asset.uri != null); + if (!Objects.equals(digest, asset.digest)) return false; + if (!Objects.equals(fd, asset.fd)) return false; + return Objects.equals(uri, asset.uri); } @Override public int hashCode() { - return Arrays.hashCode(new Object[]{data, digest, fd, uri}); + return Arrays.deepHashCode(new Object[]{data, digest, fd, uri}); } @Override diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionConfiguration.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionConfiguration.java index 214481da2b..faa0757b11 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionConfiguration.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionConfiguration.java @@ -19,20 +19,22 @@ import org.microg.safeparcel.AutoSafeParcelable; import org.microg.safeparcel.SafeParceled; +import java.util.List; + public class ConnectionConfiguration extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; @SafeParceled(2) - public final String name; + public String name; @SafeParceled(3) - public final String address; + public String address; @SafeParceled(4) - public final int type; + public int type; @SafeParceled(5) - public final int role; + public int role; @SafeParceled(6) - public final boolean enabled; + public boolean enabled; @SafeParceled(7) public boolean connected = false; @SafeParceled(8) @@ -41,28 +43,65 @@ public class ConnectionConfiguration extends AutoSafeParcelable { public boolean btlePriority = true; @SafeParceled(10) public String nodeId; + @SafeParceled(11) + public String packageName; + @SafeParceled(12) + public int connectionRetryStrategy; + @SafeParceled(13) + public List allowedConfigPackages; + @SafeParceled(14) + public boolean migrating; + @SafeParceled(15) + public boolean dataItemSyncEnabled; + @SafeParceled(16) + public ConnectionRestrictions connectionRestrictions; + @SafeParceled(17) + public boolean removeConnectionWhenBondRemovedByUser; + @SafeParceled(18) + public ConnectionDelayFilters connectionDelayFilters; + @SafeParceled(19) + public int maxSupportedRemoteAndroidSdkVersion; + @SafeParceled(20) + public int runtimeType; private ConnectionConfiguration() { - name = address = null; - type = role = 0; - enabled = false; } public ConnectionConfiguration(String name, String address, int type, int role, boolean enabled) { - this.name = name; - this.address = address; - this.type = type; - this.role = role; - this.enabled = enabled; + this(name, address, type, role, enabled, false, null, false, null, null, 0, null, false, false, null, false, null, 0, 0); + } + + public ConnectionConfiguration(String name, String address, int type, int role, boolean enabled, String nodeId, String packageName) { + this(name, address, type, role, enabled, false, null, false, nodeId, packageName, 0, null, false, false, null, false, null, 0, 0); } - public ConnectionConfiguration(String name, String address, int type, int role, boolean enabled, String nodeId) { + public ConnectionConfiguration(String name, String address, int type, int role, boolean enabled, + boolean connected, String peerNodeId, boolean btlePriority, + String nodeId, String packageName, int connectionRetryStrategy, + List allowedConfigPackages, boolean migrating, + boolean dataItemSyncEnabled, ConnectionRestrictions connectionRestrictions, + boolean removeConnectionWhenBondRemovedByUser, + ConnectionDelayFilters connectionDelayFilters, + int maxSupportedRemoteAndroidSdkVersion, int runtimeType) { this.name = name; this.address = address; this.type = type; this.role = role; this.enabled = enabled; + this.connected = connected; + this.peerNodeId = peerNodeId; + this.btlePriority = btlePriority; this.nodeId = nodeId; + this.packageName = packageName; + this.connectionRetryStrategy = connectionRetryStrategy; + this.allowedConfigPackages = allowedConfigPackages; + this.migrating = migrating; + this.dataItemSyncEnabled = dataItemSyncEnabled; + this.connectionRestrictions = connectionRestrictions; + this.removeConnectionWhenBondRemovedByUser = removeConnectionWhenBondRemovedByUser; + this.connectionDelayFilters = connectionDelayFilters; + this.maxSupportedRemoteAndroidSdkVersion = maxSupportedRemoteAndroidSdkVersion; + this.runtimeType = runtimeType; } @Override @@ -77,6 +116,15 @@ public String toString() { sb.append(", peerNodeId='").append(peerNodeId).append('\''); sb.append(", btlePriority=").append(btlePriority); sb.append(", nodeId='").append(nodeId).append('\''); + sb.append(", packageName='").append(packageName).append('\''); + sb.append(", connectionRetryStrategy='").append(connectionRetryStrategy).append('\''); + sb.append(", allowedConfigPackages='").append(allowedConfigPackages).append('\''); + sb.append(", migrating='").append(migrating).append('\''); + sb.append(", dataItemSyncEnabled='").append(dataItemSyncEnabled).append('\''); + sb.append(", connectionRestrictions='").append(connectionRestrictions).append('\''); + sb.append(", removeConnectionWhenBondRemovedByUser='").append(removeConnectionWhenBondRemovedByUser).append('\''); + sb.append(", maxSupportedRemoteAndroidSdkVersion='").append(maxSupportedRemoteAndroidSdkVersion).append('\''); + sb.append(", runtimeType='").append(runtimeType).append('\''); sb.append('}'); return sb.toString(); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionDelayConfig.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionDelayConfig.java new file mode 100644 index 0000000000..87e8d116bc --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionDelayConfig.java @@ -0,0 +1,21 @@ +package com.google.android.gms.wearable; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class ConnectionDelayConfig extends AutoSafeParcelable { + @SafeParceled(1) + public long unk1; + @SafeParceled(2) + public long unk2; + + public ConnectionDelayConfig() { + } + + public ConnectionDelayConfig(long unk1, long unk2) { + this.unk1 = unk1; + this.unk2 = unk2; + } + + public static final Creator CREATOR = new AutoCreator<>(ConnectionDelayConfig.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionDelayFilters.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionDelayFilters.java new file mode 100644 index 0000000000..60ddb670b4 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionDelayFilters.java @@ -0,0 +1,40 @@ +package com.google.android.gms.wearable; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.util.List; +import java.util.Objects; + +public class ConnectionDelayFilters extends AutoSafeParcelable { + @SafeParceled(1) + public List dataItemFilters; + + private ConnectionDelayFilters() {} + + public ConnectionDelayFilters(List dataItemFilters) { + this.dataItemFilters = dataItemFilters; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof ConnectionDelayFilters) { + return Objects.equals(this.dataItemFilters, ((ConnectionDelayFilters) obj).dataItemFilters); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(dataItemFilters); + } + + @Override + public String toString() { + return "ConnectionDelayFilters{" + + "dataItemFilters=" + dataItemFilters + + '}'; + } + + public static final Creator CREATOR = new AutoCreator<>(ConnectionDelayFilters.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionRestrictions.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionRestrictions.java new file mode 100644 index 0000000000..5cf63db2c5 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/ConnectionRestrictions.java @@ -0,0 +1,36 @@ +package com.google.android.gms.wearable; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.util.List; + +public class ConnectionRestrictions extends AutoSafeParcelable { + @SafeParceled(1) + public List allowedDataItemFilters; + @SafeParceled(2) + public List allowedCapabilities; + @SafeParceled(3) + public List allowedPackages; + + private ConnectionRestrictions() {} + + public ConnectionRestrictions(List allowedDataItemFilters, + List allowedCapabilities, + List allowedPackages) { + this.allowedDataItemFilters = allowedDataItemFilters; + this.allowedCapabilities = allowedCapabilities; + this.allowedPackages = allowedPackages; + } + + @Override + public String toString() { + return "ConnectionRestrictions{" + + "allowedDataItemFilters=" + allowedDataItemFilters + + ", allowedCapabilities=" + allowedCapabilities + + ", allowedPackages=" + allowedPackages + + '}'; + } + + public static final Creator CREATOR = new AutoCreator<>(ConnectionRestrictions.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/DataItemFilter.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/DataItemFilter.java new file mode 100644 index 0000000000..05f65cb9a8 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/DataItemFilter.java @@ -0,0 +1,46 @@ +package com.google.android.gms.wearable; + +import android.net.Uri; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.util.Objects; + +public class DataItemFilter extends AutoSafeParcelable { + @SafeParceled(1) + public Uri uri; + @SafeParceled(2) + public int filterType; + + private DataItemFilter() {} + + public DataItemFilter(Uri uri, int filterType) { + this.uri = uri; + this.filterType = filterType; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof DataItemFilter)) { + return false; + } + DataItemFilter other = (DataItemFilter) obj; + return Objects.equals(this.uri, other.uri) && this.filterType == other.filterType; + } + + @Override + public int hashCode() { + return Objects.hash(uri, filterType); + } + + @Override + public String toString() { + return "DataItemFilter{" + + "uri=" + uri + + ", filterType=" + filterType + + '}'; + } + + public static final Creator CREATOR = new AutoCreator<>(DataItemFilter.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/MessageOptions.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/MessageOptions.java new file mode 100644 index 0000000000..c31c5eef9b --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/MessageOptions.java @@ -0,0 +1,35 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class MessageOptions extends AutoSafeParcelable { + @SafeParceled(1) + public final int version = 1; + @SafeParceled(2) + public int priority; + + private MessageOptions() {} + + public MessageOptions(int priority) { + this.priority = priority; + } + + public static final Creator CREATOR = new AutoCreator(MessageOptions.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/Term.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/Term.java new file mode 100644 index 0000000000..768e2ec2ed --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/Term.java @@ -0,0 +1,32 @@ +package com.google.android.gms.wearable; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class Term extends AutoSafeParcelable { + @SafeParceled(1) + public int termType; + @SafeParceled(2) + public String description; + @SafeParceled(3) + public boolean isExplicitConsent; + @SafeParceled(4) + public String title; + @SafeParceled(5) + public String unknown5; + @SafeParceled(6) + public int optInType; + + private Term() {} + + public Term(int termType, String description, boolean isExplicitConsent, String title, String unknown5, int optInType) { + this.termType = termType; + this.description = description; + this.isExplicitConsent = isExplicitConsent; + this.title = title; + this.unknown5 = unknown5; + this.optInType = optInType; + } + + public static final Creator CREATOR = new AutoCreator(Term.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/WearableListenerService.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/WearableListenerService.java index 8aa5b091cf..0964754471 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/WearableListenerService.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/WearableListenerService.java @@ -31,9 +31,11 @@ import com.google.android.gms.wearable.internal.AncsNotificationParcelable; import com.google.android.gms.wearable.internal.CapabilityInfoParcelable; import com.google.android.gms.wearable.internal.ChannelEventParcelable; +import com.google.android.gms.wearable.internal.ConsentResponse; import com.google.android.gms.wearable.internal.IWearableListener; import com.google.android.gms.wearable.internal.MessageEventParcelable; import com.google.android.gms.wearable.internal.NodeParcelable; +import com.google.android.gms.wearable.internal.IRpcResponseCallback; import org.microg.gms.common.PublicApi; import org.microg.gms.wearable.ChannelImpl; @@ -157,105 +159,68 @@ private boolean post(Runnable runnable) { @Override public void onDataChanged(final DataHolder data) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - WearableListenerService.this.onDataChanged(new DataEventBuffer(data)); - } - }); + post(() -> WearableListenerService.this.onDataChanged(new DataEventBuffer(data))); } @Override public void onMessageReceived(final MessageEventParcelable messageEvent) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - WearableListenerService.this.onMessageReceived(messageEvent); - } - }); + post(() -> WearableListenerService.this.onMessageReceived(messageEvent)); } @Override public void onPeerConnected(final NodeParcelable node) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - WearableListenerService.this.onPeerConnected(node); - } - }); + post(() -> WearableListenerService.this.onPeerConnected(node)); } @Override public void onPeerDisconnected(final NodeParcelable node) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - WearableListenerService.this.onPeerDisconnected(node); - } - }); + post(() -> WearableListenerService.this.onPeerDisconnected(node)); } @Override public void onConnectedNodes(final List nodes) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - WearableListenerService.this.onConnectedNodes(new ArrayList(nodes)); - } - }); + post(() -> WearableListenerService.this.onConnectedNodes(new ArrayList(nodes))); } @Override public void onConnectedCapabilityChanged(final CapabilityInfoParcelable capabilityInfo) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - WearableListenerService.this.onCapabilityChanged(capabilityInfo); - } - }); + post(() -> WearableListenerService.this.onCapabilityChanged(capabilityInfo)); } @Override public void onNotificationReceived(final AncsNotificationParcelable notification) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - WearableListenerService.this.onNotificationReceived(notification); - } - }); + post(() -> WearableListenerService.this.onNotificationReceived(notification)); } @Override public void onEntityUpdate(final AmsEntityUpdateParcelable update) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - WearableListenerService.this.onEntityUpdate(update); - } - }); + post(() -> WearableListenerService.this.onEntityUpdate(update)); + } + + @Override + public void onRequest(final MessageEventParcelable messageEvent, final IRpcResponseCallback callback) throws RemoteException { + post(() -> WearableListenerService.this.onMessageReceived(messageEvent)); + if (callback != null) callback.onResponse(false, null); } @Override public void onChannelEvent(final ChannelEventParcelable channelEvent) throws RemoteException { - post(new Runnable() { - @Override - public void run() { - switch (channelEvent.eventType) { - case 1: - WearableListenerService.this.onChannelOpened(new ChannelImpl(channelEvent.channel)); - break; - case 2: - WearableListenerService.this.onChannelClosed(new ChannelImpl(channelEvent.channel), channelEvent.closeReason, channelEvent.appSpecificErrorCode); - break; - case 3: - WearableListenerService.this.onInputClosed(new ChannelImpl(channelEvent.channel), channelEvent.closeReason, channelEvent.appSpecificErrorCode); - break; - case 4: - WearableListenerService.this.onOutputClosed(new ChannelImpl(channelEvent.channel), channelEvent.closeReason, channelEvent.appSpecificErrorCode); - break; - default: - Log.w(TAG, "Unknown ChannelEvent.eventType"); - } + post(() -> { + switch (channelEvent.eventType) { + case 1: + WearableListenerService.this.onChannelOpened(new ChannelImpl(channelEvent.channel)); + break; + case 2: + WearableListenerService.this.onChannelClosed(new ChannelImpl(channelEvent.channel), channelEvent.closeReason, channelEvent.appSpecificErrorCode); + break; + case 3: + WearableListenerService.this.onInputClosed(new ChannelImpl(channelEvent.channel), channelEvent.closeReason, channelEvent.appSpecificErrorCode); + break; + case 4: + WearableListenerService.this.onOutputClosed(new ChannelImpl(channelEvent.channel), channelEvent.closeReason, channelEvent.appSpecificErrorCode); + break; + default: + Log.w(TAG, "Unknown ChannelEvent.eventType"); } }); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AcceptTermsRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AcceptTermsRequest.java new file mode 100644 index 0000000000..edaea76ef7 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AcceptTermsRequest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.util.List; + +public class AcceptTermsRequest extends AutoSafeParcelable { + @Field(1) + public int termsContext; + @Field(value = 2, useDirectList = true) + public List acceptedTermTypes; + @Field(3) + public String parentGaiaId; + @Field(4) + public String childGaiaId; + @Field(5) + public String nodeId; + @Field(6) + public String accountName; + @Field(value = 7, useDirectList = true) + public List skippedTermTypes; + @Field(8) + public boolean perWatchConsents; + + private AcceptTermsRequest() {} + + public AcceptTermsRequest(int termsContext, List acceptedTermTypes, String parentGaiaId, String childGaiaId, String nodeId, String accountName, List skippedTermTypes, boolean perWatchConsents) { + this.termsContext = termsContext; + this.acceptedTermTypes = acceptedTermTypes; + this.parentGaiaId = parentGaiaId; + this.childGaiaId = childGaiaId; + this.nodeId = nodeId; + this.accountName = accountName; + this.skippedTermTypes = skippedTermTypes; + this.perWatchConsents = perWatchConsents; + } + + public static final Creator CREATOR = new AutoCreator(AcceptTermsRequest.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AccountConsentRecordParcelable.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AccountConsentRecordParcelable.java new file mode 100644 index 0000000000..9fa1b2217b --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AccountConsentRecordParcelable.java @@ -0,0 +1,21 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class AccountConsentRecordParcelable extends AutoSafeParcelable { + @SafeParceled(1) + public String accountName; + @SafeParceled(2) + public boolean consentGranted; + + private AccountConsentRecordParcelable() {} + + public AccountConsentRecordParcelable(String accountName, boolean consentGranted) { + this.accountName = accountName; + this.consentGranted = consentGranted; + } + + public static final Creator CREATOR = + new AutoCreator(AccountConsentRecordParcelable.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AddAccountToConsentRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AddAccountToConsentRequest.java new file mode 100644 index 0000000000..efa54aec9d --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AddAccountToConsentRequest.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class AddAccountToConsentRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String accountName; + @SafeParceled(2) + public boolean consentGranted; + + private AddAccountToConsentRequest() {} + + public AddAccountToConsentRequest(String accountName, boolean consentGranted) { + this.accountName = accountName; + this.consentGranted = consentGranted; + } + + public static final Creator CREATOR = new AutoCreator(AddAccountToConsentRequest.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AddSupervisedAccountRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AddSupervisedAccountRequest.java new file mode 100644 index 0000000000..853572e868 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AddSupervisedAccountRequest.java @@ -0,0 +1,25 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class AddSupervisedAccountRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String unk1; + @SafeParceled(2) + public String unk2; + @SafeParceled(3) + public boolean unk3; + + public AddSupervisedAccountRequest() { + } + + public AddSupervisedAccountRequest(String unk1, String unk2, boolean unk3) { + this.unk1 = unk1; + this.unk2 = unk2; + this.unk3 = unk3; + } + + public static final Creator CREATOR = new AutoCreator<>(AddSupervisedAccountRequest.class); +} + diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AppRecommendationsResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AppRecommendationsResponse.java new file mode 100644 index 0000000000..8495816a99 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AppRecommendationsResponse.java @@ -0,0 +1,29 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class AppRecommendationsResponse extends AutoSafeParcelable { + @SafeParceled(1) public int statusCode; + // fields 2 (List) and 3 (WebIconParcelable) omitted until ported + + public AppRecommendationsResponse() {} + public AppRecommendationsResponse(int statusCode) { this.statusCode = statusCode; } + public static final Creator CREATOR = new AutoCreator<>(AppRecommendationsResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/BooleanResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/BooleanResponse.java new file mode 100644 index 0000000000..b6374c7598 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/BooleanResponse.java @@ -0,0 +1,55 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.util.Objects; + +public class BooleanResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int status; + @SafeParceled(2) + public boolean result; + + private BooleanResponse() {} + + public BooleanResponse(int status, boolean result) { + this.status = status; + this.result = result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof BooleanResponse)) { + return false; + } + BooleanResponse other = (BooleanResponse) obj; + return this.status == other.status && this.result == other.result; + } + + @Override + public int hashCode() { + return Objects.hash(status, result); + } + + public static final Creator CREATOR = new AutoCreator(BooleanResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelEventParcelable.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelEventParcelable.java index 39dedeeb22..eecedcb8e4 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelEventParcelable.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelEventParcelable.java @@ -20,7 +20,6 @@ import org.microg.safeparcel.SafeParceled; public class ChannelEventParcelable extends AutoSafeParcelable { - @SafeParceled(1) private int versionCode = 1; @SafeParceled(2) @@ -32,5 +31,39 @@ public class ChannelEventParcelable extends AutoSafeParcelable { @SafeParceled(5) public int appSpecificErrorCode; + public static final int EVENT_TYPE_CHANNEL_OPENED = 1; + public static final int EVENT_TYPE_CHANNEL_CLOSED = 2; + public static final int EVENT_TYPE_INPUT_CLOSED = 3; + public static final int EVENT_TYPE_OUTPUT_CLOSED = 4; + + private ChannelEventParcelable() {} + + public ChannelEventParcelable(ChannelParcelable channel, int eventType, int closeReason, int appSpecificErrorCode) { + this.channel = channel; + this.eventType = eventType; + this.closeReason = closeReason; + this.appSpecificErrorCode = appSpecificErrorCode; + } + + public String getTypeString() { + switch (eventType) { + case EVENT_TYPE_CHANNEL_OPENED: return "CHANNEL_OPENED"; + case EVENT_TYPE_CHANNEL_CLOSED: return "CHANNEL_CLOSED"; + case EVENT_TYPE_INPUT_CLOSED: return "INPUT_CLOSED"; + case EVENT_TYPE_OUTPUT_CLOSED: return "OUTPUT_CLOSED"; + default: return "UNKNOWN(" + eventType + ")"; + } + } + + @Override + public String toString() { + return "ChannelEventParcelable{" + + "channel=" + channel + + ", type=" + getTypeString() + + ", closeReason=" + closeReason + + ", appErrorCode=" + appSpecificErrorCode + + '}'; + } + public static final Creator CREATOR = new AutoCreator(ChannelEventParcelable.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelParcelable.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelParcelable.java index 79ffb070ae..c41fed5794 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelParcelable.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelParcelable.java @@ -29,8 +29,7 @@ public class ChannelParcelable extends AutoSafeParcelable { @SafeParceled(4) public String path; - private ChannelParcelable() { - } + private ChannelParcelable() {} public ChannelParcelable(String token, String nodeId, String path) { this.token = token; @@ -38,5 +37,26 @@ public ChannelParcelable(String token, String nodeId, String path) { this.path = path; } + public final int hashCode() { + return this.token.hashCode(); + } + + public final String toString() { + int i = 0; + for (char c : this.token.toCharArray()) { + i += c; + } + String strTrim = this.token.trim(); + int length = strTrim.length(); + if (length > 25) { + strTrim = strTrim.substring(0, 10) + "..." + + strTrim.substring(length - 10, length) + "::" + i; + } + return "Channel{token=" +strTrim + + ", nodeId=" + this.nodeId + + ", path=" + this.path + + "}"; + } + public static final Creator CREATOR = new AutoCreator(ChannelParcelable.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelReceiveFileResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelReceiveFileResponse.java index 9a6a05fe4b..7582c35fa2 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelReceiveFileResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelReceiveFileResponse.java @@ -22,5 +22,14 @@ public class ChannelReceiveFileResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + @SafeParceled(2) + public int status = 1; + + private ChannelReceiveFileResponse() {} + + public ChannelReceiveFileResponse(int status) { + this.status = status; + } + public static final Creator CREATOR = new AutoCreator(ChannelReceiveFileResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelSendFileResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelSendFileResponse.java index 09a2cb19a6..718bb8d168 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelSendFileResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelSendFileResponse.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2019 microG Project Team - * + *ChannelReceiveFileResponse * 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 @@ -22,5 +22,14 @@ public class ChannelSendFileResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + @SafeParceled(2) + public int status = 1; + + private ChannelSendFileResponse() {} + + public ChannelSendFileResponse(int status) { + this.status = status; + } + public static final Creator CREATOR = new AutoCreator(ChannelSendFileResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/CloseChannelResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/CloseChannelResponse.java index 3520593b35..cd0c77caaf 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/CloseChannelResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/CloseChannelResponse.java @@ -22,5 +22,14 @@ public class CloseChannelResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + @SafeParceled(2) + public int status = 1; + + private CloseChannelResponse() {} + + public CloseChannelResponse(int status) { + this.status = status; + } + public static final Creator CREATOR = new AutoCreator(CloseChannelResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ConsentResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ConsentResponse.java new file mode 100644 index 0000000000..1aa0683d0c --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ConsentResponse.java @@ -0,0 +1,87 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.util.Arrays; +import java.util.List; + +public class ConsentResponse extends AutoSafeParcelable { + + @SafeParceled(1) + public int statusCode; + @SafeParceled(2) + public boolean hasTosConsent; + @SafeParceled(3) + public boolean hasLoggingConsent; + @SafeParceled(4) + public boolean hasCloudSyncConsent; + @SafeParceled(5) + public boolean hasLocationConsent; + @SafeParceled(value = 6, subClass = AccountConsentRecordParcelable.class) + public List accountConsentRecords; + @SafeParceled(7) + public String nodeId; + @SafeParceled(8) + public Long lastUpdateRequestedTime; + + private ConsentResponse() {} + + public ConsentResponse(int statusCode, boolean hasTosConsent, boolean hasLoggingConsent, boolean hasCloudSyncConsent, boolean hasLocationConsent, List accountConsentRecords, String nodeId, Long lastUpdateRequestedTime) { + this.statusCode = statusCode; + this.hasTosConsent = hasTosConsent; + this.hasLoggingConsent = hasLoggingConsent; + this.hasCloudSyncConsent = hasCloudSyncConsent; + this.hasLocationConsent = hasLocationConsent; + this.accountConsentRecords = accountConsentRecords; + this.nodeId = nodeId; + this.lastUpdateRequestedTime = lastUpdateRequestedTime; + } + + public final int hashCode() { + return Arrays.hashCode(new Object[]{ + this.statusCode, + this.hasTosConsent, + this.hasLoggingConsent, + this.hasCloudSyncConsent, + this.hasLocationConsent, + this.accountConsentRecords, + this.nodeId, + this.lastUpdateRequestedTime + }); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ConsentResponse {"); + sb.append("\nstatusCode = ").append(this.statusCode); + sb.append("\nhasTosConsent = ").append(this.hasTosConsent); + sb.append("\nhasLoggingConsent = ").append(this.hasLoggingConsent); + sb.append("\nhasCloudSyncConsent = ").append(this.hasCloudSyncConsent); + sb.append("\nhasLocationConsent = ").append(this.hasLocationConsent); + sb.append("\naccountConsentRecords = ").append(this.accountConsentRecords); + sb.append("\nnodeId = ").append(this.nodeId); + sb.append("\nlastUpdateRequestedTime = ").append(this.lastUpdateRequestedTime); + sb.append("\n}\n"); + return sb.toString(); + + } + + public static final Creator CREATOR = new AutoCreator(ConsentResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ConsentStatusRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ConsentStatusRequest.java new file mode 100644 index 0000000000..02e7eaa028 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ConsentStatusRequest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class ConsentStatusRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String status; + + public ConsentStatusRequest(String status) { + this.status = status; + } + + public static final Creator CREATOR = new AutoCreator(ConsentStatusRequest.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/DataItemAssetParcelable.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/DataItemAssetParcelable.java index 769573602e..f58a0638e4 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/DataItemAssetParcelable.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/DataItemAssetParcelable.java @@ -30,8 +30,7 @@ public class DataItemAssetParcelable extends AutoSafeParcelable implements DataI @SafeParceled(3) private String key; - private DataItemAssetParcelable() { - } + private DataItemAssetParcelable() {} public DataItemAssetParcelable(String id, String key) { this.id = id; diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/DataItemParcelable.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/DataItemParcelable.java index 837b5fa94d..8d512d87ef 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/DataItemParcelable.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/DataItemParcelable.java @@ -38,8 +38,7 @@ public class DataItemParcelable extends AutoSafeParcelable implements DataItem { @SafeParceled(5) public byte[] data; - private DataItemParcelable() { - } + private DataItemParcelable() {} public DataItemParcelable(Uri uri) { this(uri, new HashMap()); diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/FastPairAccountKeyParcelable.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/FastPairAccountKeyParcelable.java new file mode 100644 index 0000000000..ca5b8a7520 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/FastPairAccountKeyParcelable.java @@ -0,0 +1,26 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.nio.ByteBuffer; + +public class FastPairAccountKeyParcelable extends AutoSafeParcelable { + @SafeParceled(1) + public byte[] accountKey; + + private FastPairAccountKeyParcelable() {} + + public FastPairAccountKeyParcelable(byte[] accountKey) { + this.accountKey = accountKey; + } + + public FastPairAccountKeyParcelable(ByteBuffer buffer) { + ByteBuffer copy = buffer.duplicate(); + this.accountKey = new byte[copy.remaining()]; + copy.get(this.accountKey); + } + + public static final Creator CREATOR = + new AutoCreator(FastPairAccountKeyParcelable.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetAllCapabilitiesResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetAllCapabilitiesResponse.java index 56ed071cc2..86a5b4bec8 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetAllCapabilitiesResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetAllCapabilitiesResponse.java @@ -29,5 +29,12 @@ public class GetAllCapabilitiesResponse extends AutoSafeParcelable { @Field(3) public List capabilities; + private GetAllCapabilitiesResponse() {} + + public GetAllCapabilitiesResponse(int statusCode, List capabilities) { + this.statusCode = statusCode; + this.capabilities = capabilities; + } + public static final Creator CREATOR = findCreator(GetAllCapabilitiesResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetAppThemeResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetAppThemeResponse.java new file mode 100644 index 0000000000..51a5023e61 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetAppThemeResponse.java @@ -0,0 +1,34 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import com.google.android.gms.wearable.AppTheme; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetAppThemeResponse extends AutoSafeParcelable { + @SafeParceled(2) public int statusCode; + @SafeParceled(3) public AppTheme theme; + + public GetAppThemeResponse() {} + public GetAppThemeResponse(int statusCode, AppTheme theme) { + this.statusCode = statusCode; + this.theme = theme; + } + public static final Creator CREATOR = new AutoCreator<>(GetAppThemeResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetBackupSettingsSupportedResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetBackupSettingsSupportedResponse.java new file mode 100644 index 0000000000..169bb1fb17 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetBackupSettingsSupportedResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetBackupSettingsSupportedResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int statusCode; + @SafeParceled(2) + public boolean supported; + + private GetBackupSettingsSupportedResponse() { + } + + public GetBackupSettingsSupportedResponse(int statusCode, boolean supported) { + this.statusCode = statusCode; + this.supported = supported; + } + + public static final Creator CREATOR = + new AutoCreator<>(GetBackupSettingsSupportedResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelInputStreamResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelInputStreamResponse.java index b5460a4373..2bff0caddb 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelInputStreamResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelInputStreamResponse.java @@ -16,11 +16,25 @@ package com.google.android.gms.wearable.internal; +import android.os.ParcelFileDescriptor; + import org.microg.safeparcel.AutoSafeParcelable; import org.microg.safeparcel.SafeParceled; public class GetChannelInputStreamResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + @SafeParceled(2) + public int statusCode; + @SafeParceled(3) + private ParcelFileDescriptor descriptor; + + private GetChannelInputStreamResponse() {} + + public GetChannelInputStreamResponse(int statusCode, ParcelFileDescriptor descriptor) { + this.statusCode = statusCode; + this.descriptor = descriptor; + } + public static final Creator CREATOR = new AutoCreator(GetChannelInputStreamResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelOutputStreamResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelOutputStreamResponse.java index 71e024e20b..60a1d77e66 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelOutputStreamResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelOutputStreamResponse.java @@ -16,11 +16,24 @@ package com.google.android.gms.wearable.internal; +import android.os.ParcelFileDescriptor; + import org.microg.safeparcel.AutoSafeParcelable; import org.microg.safeparcel.SafeParceled; public class GetChannelOutputStreamResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + @SafeParceled(2) + public int statusCode; + @SafeParceled(3) + private ParcelFileDescriptor descriptor; + + private GetChannelOutputStreamResponse() {} + + public GetChannelOutputStreamResponse(int statusCode, ParcelFileDescriptor descriptor) { + this.statusCode = statusCode; + this.descriptor = descriptor; + } public static final Creator CREATOR = new AutoCreator(GetChannelOutputStreamResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCloudSyncOptInOutDoneResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCloudSyncOptInOutDoneResponse.java index 1c3c63ec74..f72a051804 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCloudSyncOptInOutDoneResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCloudSyncOptInOutDoneResponse.java @@ -22,5 +22,17 @@ public class GetCloudSyncOptInOutDoneResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + @SafeParceled(2) + public int statusCode; + @SafeParceled(3) + public boolean isOptedIn; + + public GetCloudSyncOptInOutDoneResponse() {} + + public GetCloudSyncOptInOutDoneResponse(int statusCode, boolean isOptedIn) { + this.statusCode = statusCode; + this.isOptedIn = isOptedIn; + } + public static final Creator CREATOR = new AutoCreator(GetCloudSyncOptInOutDoneResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCloudSyncOptInStatusResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCloudSyncOptInStatusResponse.java index da21331261..b4aaf5d3ad 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCloudSyncOptInStatusResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCloudSyncOptInStatusResponse.java @@ -22,5 +22,20 @@ public class GetCloudSyncOptInStatusResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + @SafeParceled(2) + public int statusCode; + @SafeParceled(3) + public boolean isOptedIn; + @SafeParceled(4) + public boolean isDone; + + public GetCloudSyncOptInStatusResponse() {} + + public GetCloudSyncOptInStatusResponse(int statusCode, boolean isOptedIn, boolean isDone) { + this.statusCode = statusCode; + this.isOptedIn = isOptedIn; + this.isDone = isDone; + } + public static final Creator CREATOR = new AutoCreator(GetCloudSyncOptInStatusResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCompanionPackageForNodeResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCompanionPackageForNodeResponse.java new file mode 100644 index 0000000000..2e269025c7 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetCompanionPackageForNodeResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetCompanionPackageForNodeResponse extends AutoSafeParcelable { + @SafeParceled(1) + private final int version = 1; + @SafeParceled(2) + private int statusCode; + @SafeParceled(3) + private String packageName; + + private GetCompanionPackageForNodeResponse() {} + + public GetCompanionPackageForNodeResponse(int statusCode, String packageName) { + this.statusCode = statusCode; + this.packageName = packageName; + } + + public static final Creator CREATOR = new AutoCreator(GetCompanionPackageForNodeResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetEapIdResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetEapIdResponse.java new file mode 100644 index 0000000000..2d80e07ab4 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetEapIdResponse.java @@ -0,0 +1,40 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetEapIdResponse extends AutoSafeParcelable { + @SafeParceled(1) + private final int ver = 1; + @SafeParceled(2) + public int status; + @SafeParceled(3) + public String eapId; + + private GetEapIdResponse() {} + + public GetEapIdResponse(int status, String eapId) { + this.status = status; + this.eapId = eapId; + } + + + + public static final Creator CREATOR = new AutoCreator(GetEapIdResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetFastpairAccountKeyByAccountResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetFastpairAccountKeyByAccountResponse.java new file mode 100644 index 0000000000..8e05b6e213 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetFastpairAccountKeyByAccountResponse.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetFastpairAccountKeyByAccountResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int status; + @SafeParceled(2) + public FastPairAccountKeyParcelable accountKey; + + private GetFastpairAccountKeyByAccountResponse() {} + + public GetFastpairAccountKeyByAccountResponse(int status, FastPairAccountKeyParcelable accountKey) { + this.status = status; + this.accountKey = accountKey; + } + + public static final Creator CREATOR = new AutoCreator(GetFastpairAccountKeyByAccountResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetFastpairAccountKeysResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetFastpairAccountKeysResponse.java new file mode 100644 index 0000000000..fc577cb51f --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetFastpairAccountKeysResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.util.List; + +public class GetFastpairAccountKeysResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int status; + @SafeParceled(2) + public List accountKeys; + + private GetFastpairAccountKeysResponse() {} + + public GetFastpairAccountKeysResponse(int status, List accountKeys) { + this.status = status; + this.accountKeys = accountKeys; + } + + public static final Creator CREATOR = new AutoCreator(GetFastpairAccountKeysResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetNodeIdResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetNodeIdResponse.java new file mode 100644 index 0000000000..e2bcb93fe3 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetNodeIdResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetNodeIdResponse extends AutoSafeParcelable { + @SafeParceled(1) + private final int ver = 1; + @SafeParceled(2) + public int status; + @SafeParceled(3) + public String nodeId; + + private GetNodeIdResponse() {} + + public GetNodeIdResponse(int status, String nodeId) { + this.status = status; + this.nodeId = nodeId; + } + + public static final Creator CREATOR = new AutoCreator(GetNodeIdResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreStateRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreStateRequest.java new file mode 100644 index 0000000000..bf3b7a864c --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreStateRequest.java @@ -0,0 +1,19 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetRestoreStateRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String nodeId; + + private GetRestoreStateRequest() { + } + + public GetRestoreStateRequest(String nodeId) { + this.nodeId = nodeId; + } + + public static final Creator CREATOR = + new AutoCreator<>(GetRestoreStateRequest.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreStateResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreStateResponse.java new file mode 100644 index 0000000000..9f3b2bb41e --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreStateResponse.java @@ -0,0 +1,41 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetRestoreStateResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int statusCode; + @SafeParceled(2) + public int state; + @SafeParceled(3) + public byte[] data; + + private GetRestoreStateResponse() { + } + + public GetRestoreStateResponse(int statusCode, int state, byte[] data) { + this.statusCode = statusCode; + this.state = state; + this.data = data; + } + + public static final Creator CREATOR = + new AutoCreator<>(GetRestoreStateResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreSupportedResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreSupportedResponse.java new file mode 100644 index 0000000000..68cbf62501 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreSupportedResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetRestoreSupportedResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int statusCode; + @SafeParceled(2) + public boolean supported; + + private GetRestoreSupportedResponse() { + } + + public GetRestoreSupportedResponse(int statusCode, boolean supported) { + this.statusCode = statusCode; + this.supported = supported; + } + + public static final Creator CREATOR = + new AutoCreator<>(GetRestoreSupportedResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetTermsResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetTermsResponse.java new file mode 100644 index 0000000000..488ec750ec --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetTermsResponse.java @@ -0,0 +1,40 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import com.google.android.gms.wearable.Term; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +import java.util.List; + +public class GetTermsResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int statusCode; + @SafeParceled(value = 2, subClass = Term.class) + public List consents; // correct name is unknown, but assuming this is a consent list + + private GetTermsResponse() {} + + public GetTermsResponse(int statusCode, List consents) { + this.statusCode = statusCode; + this.consents = consents; + } + + public static final Creator CREATOR = new AutoCreator(GetTermsResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogCounterRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogCounterRequest.java new file mode 100644 index 0000000000..8b5d965b9d --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogCounterRequest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class LogCounterRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String counterName; + @SafeParceled(2) + public long value; + @SafeParceled(3) + public byte[] counterData; + @SafeParceled(4) + public long timestamp; + @SafeParceled(5) + public boolean increment; + + private LogCounterRequest() {} + + public LogCounterRequest(String counterName, long value, byte[] counterData, long timestamp, boolean increment) { + this.counterName = counterName; + this.value = value; + this.counterData = counterData; + this.timestamp = timestamp; + this.increment = increment; + } + + @Override + public String toString() { + return "LogCounterRequest{counterName='" + counterName + "', value=" + value + + ", timestamp=" + timestamp + ", increment=" + increment + + ", counterData.length=" + (counterData != null ? counterData.length : 0) + "}"; + } + + public static final Creator CREATOR = new AutoCreator<>(LogCounterRequest.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogEventRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogEventRequest.java new file mode 100644 index 0000000000..04c0bddf2a --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogEventRequest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class LogEventRequest extends AutoSafeParcelable { + @SafeParceled(1) + public byte[] eventData; + + private LogEventRequest() {} + + public LogEventRequest(byte[] eventData) { + this.eventData = eventData; + } + + @Override + public String toString() { + return "LogEventRequest{eventData.length=" + (eventData != null ? eventData.length : 0) + "}"; + } + + public static final Creator CREATOR = new AutoCreator<>(LogEventRequest.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogTimerRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogTimerRequest.java new file mode 100644 index 0000000000..f42443dd0d --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/LogTimerRequest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class LogTimerRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String timerName; + @SafeParceled(2) + public long timestamp; + @SafeParceled(3) + public byte[] timerData; + + private LogTimerRequest() {} + + public LogTimerRequest(String timerName, long timestamp, byte[] timerData) { + this.timerName = timerName; + this.timestamp = timestamp; + this.timerData = timerData; + } + + @Override + public String toString() { + return "LogTimerRequest{timerName='" + timerName + "', timestamp=" + timestamp + + ", timerData.length=" + (timerData != null ? timerData.length : 0) + "}"; + } + + public static final Creator CREATOR = new AutoCreator<>(LogTimerRequest.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/MessageEventParcelable.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/MessageEventParcelable.java index 1eb95c32a2..8cbaa566df 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/MessageEventParcelable.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/MessageEventParcelable.java @@ -34,6 +34,15 @@ public class MessageEventParcelable extends AutoSafeParcelable implements Messag @SafeParceled(5) public String sourceNodeId; + private MessageEventParcelable() {} + + public MessageEventParcelable(int requestId, String path, byte[] data, String sourceNodeId) { + this.requestId = requestId; + this.path = path; + this.data = data; + this.sourceNodeId = sourceNodeId; + } + @Override public byte[] getData() { return data; diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/NodeParcelable.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/NodeParcelable.java index 1c8af18b66..6207e3b907 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/NodeParcelable.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/NodeParcelable.java @@ -92,7 +92,7 @@ public int hashCode() { @Override public String toString() { - return "NodeParcelable{" + displayName + ", id=" + displayName + ", hops=" + hops + ", isNearby=" + isNearby + "}"; + return "NodeParcelable{" + displayName + ", id=" + nodeId + ", hops=" + hops + ", isNearby=" + isNearby + "}"; } public static final Creator CREATOR = new AutoCreator(NodeParcelable.class); diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/OpenChannelResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/OpenChannelResponse.java index fcd97a228e..b8539b4d02 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/OpenChannelResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/OpenChannelResponse.java @@ -16,11 +16,24 @@ package com.google.android.gms.wearable.internal; +import org.microg.gms.wearable.ChannelImpl; import org.microg.safeparcel.AutoSafeParcelable; import org.microg.safeparcel.SafeParceled; public class OpenChannelResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + @SafeParceled(2) + public int statusCode; + @SafeParceled(3) + public ChannelParcelable channel; + + private OpenChannelResponse() {} + + public OpenChannelResponse (int statusCode, ChannelParcelable channel) { + this.statusCode = statusCode; + this.channel = channel; + } + public static final Creator CREATOR = new AutoCreator(OpenChannelResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PackageStorageInfo.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PackageStorageInfo.java new file mode 100644 index 0000000000..c711641050 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PackageStorageInfo.java @@ -0,0 +1,26 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class PackageStorageInfo extends AutoSafeParcelable { + @SafeParceled(1) + private final int version = 1; + @SafeParceled(2) + public String packageName; + @SafeParceled(3) + public String appLabel; + @SafeParceled(4) + public long size; + + private PackageStorageInfo() {} + + public PackageStorageInfo(String packageName, String appLabel, long size) { + this.packageName = packageName; + this.appLabel = appLabel; + this.size = size; + } + + public static final Creator CREATOR = + new AutoCreator<>(PackageStorageInfo.class); +} \ No newline at end of file diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PerformEapAkaResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PerformEapAkaResponse.java new file mode 100644 index 0000000000..066f147654 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PerformEapAkaResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class PerformEapAkaResponse extends AutoSafeParcelable { + @SafeParceled(1) + private final int ver = 1; + @SafeParceled(2) + public int status; + @SafeParceled(3) + public String someStr; + + private PerformEapAkaResponse() {} + + public PerformEapAkaResponse(int status, String someStr) { + this.status = status; + this.someStr = someStr; + } + + public static final Creator CREATOR = new AutoCreator(PerformEapAkaResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PrivacyRecordOptinRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PrivacyRecordOptinRequest.java new file mode 100644 index 0000000000..418492a95f --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PrivacyRecordOptinRequest.java @@ -0,0 +1,29 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class PrivacyRecordOptinRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String packageName; + @SafeParceled(2) + public int optInType; + @SafeParceled(3) + public boolean optedIn; + @SafeParceled(4) + public String nodeId; + + private PrivacyRecordOptinRequest() { + } + + public PrivacyRecordOptinRequest(String packageName, int optInType, + boolean optedIn, String nodeId) { + this.packageName = packageName; + this.optInType = optInType; + this.optedIn = optedIn; + this.nodeId = nodeId; + } + + public static final Creator CREATOR = + new AutoCreator<>(PrivacyRecordOptinRequest.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PutDataRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PutDataRequest.java index ec9d6b2fc6..1d9234c420 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PutDataRequest.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PutDataRequest.java @@ -23,11 +23,13 @@ import com.google.android.gms.wearable.Asset; import com.google.android.gms.wearable.DataItem; +import com.google.android.gms.wearable.DataItemAsset; import org.microg.gms.common.PublicApi; import org.microg.safeparcel.AutoSafeParcelable; import org.microg.safeparcel.SafeParceled; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -53,14 +55,35 @@ public class PutDataRequest extends AutoSafeParcelable { private PutDataRequest() { uri = null; assets = new Bundle(); + initializeAssetsClassLoader(); } private PutDataRequest(Uri uri) { this.uri = uri; assets = new Bundle(); + initializeAssetsClassLoader(); } + private PutDataRequest(Uri uri, Bundle assets, byte[] data, long syncDeadline) { + this.uri = uri; + this.assets = assets; + this.data = data; + this.syncDeadline = syncDeadline; + initializeAssetsClassLoader(); + } + + private void initializeAssetsClassLoader() { + ClassLoader classLoader = DataItemAssetParcelable.class.getClassLoader(); + if (classLoader != null) { + assets.setClassLoader(classLoader); + } + } + + public static PutDataRequest create(Uri uri) { + if (uri == null) { + throw new IllegalArgumentException("uri must not be null"); + } return new PutDataRequest(uri); } @@ -77,17 +100,45 @@ public static PutDataRequest create(String path) { } public static PutDataRequest createFromDataItem(DataItem source) { + if (source == null) { + throw new IllegalArgumentException("source must not be null"); + } PutDataRequest dataRequest = new PutDataRequest(source.getUri()); dataRequest.data = source.getData(); - // TODO: assets + + Map sourceAssets = source.getAssets(); + if (sourceAssets != null) { + for (Map.Entry entry : sourceAssets.entrySet()) { + DataItemAsset itemAsset = entry.getValue(); + if (itemAsset != null && itemAsset.getId() != null) { + Asset asset = Asset.createFromRef(itemAsset.getId()); + dataRequest.putAsset(entry.getKey(), asset); + } + } + } return dataRequest; } public static PutDataRequest createWithAutoAppendedId(String pathPrefix) { - return new PutDataRequest(null); + if (TextUtils.isEmpty(pathPrefix)) { + throw new IllegalArgumentException("An empty pathPrefix was supplied."); + } else if (!pathPrefix.startsWith("/")) { + throw new IllegalArgumentException("A pathPrefix must start with a single / ."); + } else if (pathPrefix.startsWith("//")) { + throw new IllegalArgumentException("A pathPrefix must start with a single / ."); + } + String uniqueId = Long.toHexString(System.currentTimeMillis()) + + Long.toHexString(Double.doubleToLongBits(Math.random())); + String path = pathPrefix.endsWith("/") ? pathPrefix + uniqueId : pathPrefix + "/" + uniqueId; + return create(path); } public Asset getAsset(String key) { + if (key == null) { + return null; + } + + assets.setClassLoader(DataItemAssetParcelable.class.getClassLoader()); return assets.getParcelable(key); } @@ -97,7 +148,7 @@ public Map getAssets() { for (String key : assets.keySet()) { map.put(key, (Asset) assets.getParcelable(key)); } - return map; + return Collections.unmodifiableMap(map); } public byte[] getData() { @@ -113,8 +164,15 @@ public boolean hasAsset(String key) { } public PutDataRequest putAsset(String key, Asset value) { + if (key == null) { + throw new IllegalArgumentException("key must not be null"); + } + if (value == null) { + throw new IllegalArgumentException("value must not be null"); + } assets.putParcelable(key, value); return this; + } public PutDataRequest removeAsset(String key) { @@ -127,6 +185,15 @@ public PutDataRequest setData(byte[] data) { return this; } + public PutDataRequest setUrgent(boolean urgent) { + this.syncDeadline = urgent ? 0 : DEFAULT_SYNC_DEADLINE; + return this; + } + + public boolean isUrgent() { + return syncDeadline == 0; + } + @Override public String toString() { return toString(false); @@ -134,17 +201,21 @@ public String toString() { public String toString(boolean verbose) { StringBuilder sb = new StringBuilder(); - sb.append("PutDataRequest[uri=").append(uri) - .append(", data=").append(data == null ? "null" : Base64.encodeToString(data, Base64.NO_WRAP)) - .append(", numAssets=").append(getAssets().size()); + sb.append("PutDataRequest["); + sb.append("dataSz=").append(data == null ? "null" : data.length); + sb.append(", numAssets=").append(assets.size()); + sb.append(", uri=").append(uri); + sb.append(", syncDeadline=").append(syncDeadline); + if (verbose && !getAssets().isEmpty()) { - sb.append(", assets=["); - for (String key : getAssets().keySet()) { - sb.append(key).append('=').append(getAsset(key)).append(", "); + sb.append("]\n assets: "); + for (Map.Entry entry : getAssets().entrySet()) { + sb.append("\n ").append(entry.getKey()).append(": ").append(entry.getValue()); } - sb.delete(sb.length() - 2, sb.length()).append(']'); + sb.append("\n ]"); + } else { + sb.append("]"); } - sb.append("]"); return sb.toString(); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RecordTermConsentRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RecordTermConsentRequest.java new file mode 100644 index 0000000000..ebc88b6f03 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RecordTermConsentRequest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class RecordTermConsentRequest extends AutoSafeParcelable { + @SafeParceled(1) + public final int termsContext; + @SafeParceled(2) + public final int termType; + @SafeParceled(3) + public final boolean consentGranted; + @SafeParceled(4) + public final String parentGaiaId; + @SafeParceled(5) + public final String childGaiaId; + @SafeParceled(6) + public final String accountId; + + public RecordTermConsentRequest(int termsContext, int termType, boolean consentGranted, String parentGaiaId, String childGaiaId, String accountId) { + this.termsContext = termsContext; + this.termType = termType; + this.consentGranted = consentGranted; + this.parentGaiaId = parentGaiaId; + this.childGaiaId = childGaiaId; + this.accountId = accountId; + } + + public static final Creator CREATOR = new AutoCreator(RecordTermConsentRequest.class); + +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RecordUntetheredSupervisedAccountTransferRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RecordUntetheredSupervisedAccountTransferRequest.java new file mode 100644 index 0000000000..b0919e6536 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RecordUntetheredSupervisedAccountTransferRequest.java @@ -0,0 +1,25 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class RecordUntetheredSupervisedAccountTransferRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String unk1; + @SafeParceled(2) + public String unk2; + @SafeParceled(3) + public boolean unk3; + + public RecordUntetheredSupervisedAccountTransferRequest() { + } + + public RecordUntetheredSupervisedAccountTransferRequest(String unk1, String unk2, boolean unk3) { + this.unk1 = unk1; + this.unk2 = unk2; + this.unk3 = unk3; + } + + public static final Creator CREATOR = new AutoCreator<>(RecordUntetheredSupervisedAccountTransferRequest.class); +} + diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RpcResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RpcResponse.java new file mode 100644 index 0000000000..c4f01fe1d4 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/RpcResponse.java @@ -0,0 +1,39 @@ +/* + * Copyright 2013-2025 microG Project Team + * + * 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 com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class RpcResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int statusCode; + @SafeParceled(2) + public int requestId; + @SafeParceled(3) + public byte[] data; + + private RpcResponse() {} + + public RpcResponse(int statusCode, int requestId, byte[] data) { + this.statusCode = statusCode; + this.requestId = requestId; + this.data = data; + } + + public static final Creator CREATOR = new AutoCreator(RpcResponse.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/SaveRestoreStateRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/SaveRestoreStateRequest.java new file mode 100644 index 0000000000..f7d07fc965 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/SaveRestoreStateRequest.java @@ -0,0 +1,25 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class SaveRestoreStateRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String nodeId; + @SafeParceled(2) + public int state; + @SafeParceled(3) + public byte[] data; + + private SaveRestoreStateRequest() { + } + + public SaveRestoreStateRequest(String nodeId, int state, byte[] data) { + this.nodeId = nodeId; + this.state = state; + this.data = data; + } + + public static final Creator CREATOR = + new AutoCreator<>(SaveRestoreStateRequest.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/StartRestoreSessionRequest.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/StartRestoreSessionRequest.java new file mode 100644 index 0000000000..369376c824 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/StartRestoreSessionRequest.java @@ -0,0 +1,19 @@ +package com.google.android.gms.wearable.internal; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class StartRestoreSessionRequest extends AutoSafeParcelable { + @SafeParceled(1) + public String nodeId; + + private StartRestoreSessionRequest() { + } + + public StartRestoreSessionRequest(String nodeId) { + this.nodeId = nodeId; + } + + public static final Creator CREATOR = + new AutoCreator<>(StartRestoreSessionRequest.class); +} diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/StorageInfoResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/StorageInfoResponse.java index 53738b20cb..790236a15b 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/StorageInfoResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/StorageInfoResponse.java @@ -17,7 +17,28 @@ package com.google.android.gms.wearable.internal; import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; public class StorageInfoResponse extends AutoSafeParcelable { - public static final Creator CREATOR = new AutoCreator(StorageInfoResponse.class); + @SafeParceled(1) + private final int version = 1; + @SafeParceled(2) + public int statusCode; + @SafeParceled(3) + public long totalSize; + @SafeParceled(4) + public PackageStorageInfo[] packageStorageInfo; + + private StorageInfoResponse() {} + + public StorageInfoResponse(int statusCode, long totalSize, + PackageStorageInfo[] packageStorageInfo) { + this.statusCode = statusCode; + this.totalSize = totalSize; + this.packageStorageInfo = packageStorageInfo; + } + + public static final Creator CREATOR = + new AutoCreator<>(StorageInfoResponse.class); } + diff --git a/play-services-wearable/src/main/java/org/microg/gms/wearable/BaseWearableCallbacks.java b/play-services-wearable/src/main/java/org/microg/gms/wearable/BaseWearableCallbacks.java index 14778527a5..e12c86d541 100644 --- a/play-services-wearable/src/main/java/org/microg/gms/wearable/BaseWearableCallbacks.java +++ b/play-services-wearable/src/main/java/org/microg/gms/wearable/BaseWearableCallbacks.java @@ -22,27 +22,42 @@ import com.google.android.gms.common.api.Status; import com.google.android.gms.common.data.DataHolder; import com.google.android.gms.wearable.internal.AddLocalCapabilityResponse; +import com.google.android.gms.wearable.internal.AppRecommendationsResponse; +import com.google.android.gms.wearable.internal.BooleanResponse; import com.google.android.gms.wearable.internal.ChannelReceiveFileResponse; import com.google.android.gms.wearable.internal.ChannelSendFileResponse; import com.google.android.gms.wearable.internal.CloseChannelResponse; +import com.google.android.gms.wearable.internal.ConsentResponse; import com.google.android.gms.wearable.internal.DeleteDataItemsResponse; import com.google.android.gms.wearable.internal.GetAllCapabilitiesResponse; +import com.google.android.gms.wearable.internal.GetAppThemeResponse; +import com.google.android.gms.wearable.internal.GetBackupSettingsSupportedResponse; import com.google.android.gms.wearable.internal.GetCapabilityResponse; import com.google.android.gms.wearable.internal.GetChannelInputStreamResponse; import com.google.android.gms.wearable.internal.GetChannelOutputStreamResponse; import com.google.android.gms.wearable.internal.GetCloudSyncOptInOutDoneResponse; import com.google.android.gms.wearable.internal.GetCloudSyncOptInStatusResponse; import com.google.android.gms.wearable.internal.GetCloudSyncSettingResponse; +import com.google.android.gms.wearable.internal.GetCompanionPackageForNodeResponse; import com.google.android.gms.wearable.internal.GetConfigResponse; import com.google.android.gms.wearable.internal.GetConfigsResponse; import com.google.android.gms.wearable.internal.GetConnectedNodesResponse; import com.google.android.gms.wearable.internal.GetDataItemResponse; +import com.google.android.gms.wearable.internal.GetEapIdResponse; +import com.google.android.gms.wearable.internal.GetFastpairAccountKeyByAccountResponse; +import com.google.android.gms.wearable.internal.GetFastpairAccountKeysResponse; import com.google.android.gms.wearable.internal.GetFdForAssetResponse; import com.google.android.gms.wearable.internal.GetLocalNodeResponse; +import com.google.android.gms.wearable.internal.GetNodeIdResponse; +import com.google.android.gms.wearable.internal.GetRestoreStateResponse; +import com.google.android.gms.wearable.internal.GetRestoreSupportedResponse; +import com.google.android.gms.wearable.internal.GetTermsResponse; import com.google.android.gms.wearable.internal.IWearableCallbacks; import com.google.android.gms.wearable.internal.OpenChannelResponse; +import com.google.android.gms.wearable.internal.PerformEapAkaResponse; import com.google.android.gms.wearable.internal.PutDataResponse; import com.google.android.gms.wearable.internal.RemoveLocalCapabilityResponse; +import com.google.android.gms.wearable.internal.RpcResponse; import com.google.android.gms.wearable.internal.SendMessageResponse; import com.google.android.gms.wearable.internal.StorageInfoResponse; @@ -115,6 +130,12 @@ public void onCloseChannelResponse(CloseChannelResponse response) throws RemoteE } + @Override + public void onCloseChannelResponse2(CloseChannelResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onCloseChannelResponse2"); + + } + @Override public void onGetChannelInputStreamResponse(GetChannelInputStreamResponse response) throws RemoteException { Log.d(TAG, "unimplemented Method: onGetChannelInputStreamResponse"); @@ -175,6 +196,82 @@ public void onRemoveLocalCapabilityResponse(RemoveLocalCapabilityResponse respon } + @Override + public void onGetTermsResponse(GetTermsResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetTermsResponse"); + + } + + @Override + public void onConsentResponse(ConsentResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onConsentResponse"); + } + + @Override + public void onGetFastpairAccountKeyByAccountResponse(GetFastpairAccountKeyByAccountResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetFastpairAccountKeyByAccountResponse"); + } + + @Override + public void onGetFastpairAccountKeysResponse(GetFastpairAccountKeysResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetFastpairAccountKeysResponse"); + } + + @Override + public void onGetRestoreStateResponse(GetRestoreStateResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetRestoreStateResponse"); + } + + @Override + public void onBooleanResponse(BooleanResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onBooleanResponse"); + } + + @Override + public void onGetCompanionPackageForNodeResponse(GetCompanionPackageForNodeResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetCompanionPackageForNodeResponse"); + } + + @Override + public void onRpcResponse(RpcResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onRpcResponse"); + } + + @Override + public void onGetEapIdResponse(GetEapIdResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetEapIdResponse"); + } + + @Override + public void onPerformEapAkaResponse(PerformEapAkaResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onPerformEapAkaResponse"); + } + + @Override + public void onGetNodeIdResponse(GetNodeIdResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetNodeIdResponse"); + } + + @Override + public void onAppRecommendationsResponse(AppRecommendationsResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onAppRecommendationsResponse"); + } + + @Override + public void onGetAppThemeResponse(GetAppThemeResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetAppThemeResponse"); + } + + @Override + public void onGetBackupSettingsSupportedResponse(GetBackupSettingsSupportedResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetBackupSettingsSupportedResponse"); + } + + @Override + public void onGetRestoreSupportedResponse(GetRestoreSupportedResponse response) throws RemoteException { + Log.d(TAG, "unimplemented Method: onGetRestoreSupportedResponse"); + } + @Override public void onGetConfigsResponse(GetConfigsResponse response) throws RemoteException { Log.d(TAG, "unimplemented Method: onGetConfigsResponse");