diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle index 5648333872..683c1bf506 100644 --- a/play-services-core/build.gradle +++ b/play-services-core/build.gradle @@ -68,6 +68,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 6f593efdf8..5d996b908e 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -465,6 +465,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..16ac9ac5c6 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,51 @@ package com.google.android.gms.wearable.consent +import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import com.google.android.gms.R +import com.google.android.material.button.MaterialButton + class TermsOfServiceActivity : AppCompatActivity() { + private var acceptButton: MaterialButton? = null + private var declineButton: MaterialButton? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setResult(RESULT_CANCELED) +// setResult(RESULT_CANCELED) +// finish() + + // TODO: make consent list + setContentView(R.layout.activity_wearable_tos); + + acceptButton = findViewById(R.id.terms_of_service_accept_button); + declineButton = findViewById(R.id.terms_of_service_decline_button); + + acceptButton?.setOnClickListener { acceptConsents() } + declineButton?.setOnClickListener { declineConsents() } + + } + + private fun acceptConsents() { + val result = Intent().apply { + putExtra("consents_accepted", true) + putExtra("tos_accepted", true) + putExtra("privacy_policy_accepted", true) + } + + setResult(RESULT_OK, result) + finish() + } + + private fun declineConsents() { + val result = Intent().apply { + putExtra("consents_accepted", false) + } + + setResult(RESULT_CANCELED, result) finish() } } \ 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..9bcd95b9ee --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearFragment.kt @@ -0,0 +1,92 @@ +/* + * 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.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.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 + } + + 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 4e202752d1..056f126fda 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 @@ + @@ -188,6 +191,26 @@ android:name="org.microg.gms.ui.WorkProfileFragment" android:label="@string/service_name_work_profile" /> + + + + + + + + + + Google SafetyNet Play Store services Work profile + WearOS Google Play Games %1$s would like to use Play Games @@ -314,6 +315,16 @@ 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? + 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 8d15cd0773..f8c1af0f18 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..8da0e0402c 100644 --- a/play-services-wearable/core/src/main/AndroidManifest.xml +++ b/play-services-wearable/core/src/main/AndroidManifest.xml @@ -6,6 +6,15 @@ + + + + + + + + + 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..cf8f60af60 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/AssetFetcher.java @@ -0,0 +1,321 @@ +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 = 10; + 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 + + 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; + } + + 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; + } + + String assetDigest = cursor.getString(13); + String assetName = cursor.getString(12); + String packageName = cursor.getString(1); + String signatureDigest = cursor.getString(2); + + 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(assetName) + .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/CapabilityManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CapabilityManager.java index 0c8f59ffc6..b127f903f5 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()); @@ -56,30 +91,110 @@ private Uri buildCapabilityUri(String capability, boolean withAuthority) { public Set getNodesForCapability(String capability) { DataHolder dataHolder = 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 < dataHolder.getCount(); i++) { + nodes.add(dataHolder.getString("host", i, 0)); + } + } finally { + dataHolder.close(); } - dataHolder.close(); 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 == CapabilityType.STATIC || type == CapabilityType.DYNAMIC) { + 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..b877a4b692 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,29 +19,34 @@ import android.content.Context; import android.content.SharedPreferences; -import java.util.UUID; +import java.security.SecureRandom; +import java.util.Random; 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 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(); + nodeId = Integer.toHexString(random.nextInt()); preferences.edit().putString(CLOCKWORK_NODE_PREFERENCE_NODE_ID, nodeId).apply(); } return nodeId; @@ -49,14 +54,46 @@ 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(); + seqIdBlock = 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 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..79422c90a5 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,147 @@ 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"; + public ConfigurationDatabaseHelper(Context context) { - super(context, "connectionconfig.db", null, 2); + super(context, "connectionconfig.db", null, 11); } @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);"); +// 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," + + " UNIQUE(" + COLUMN_NAME + ") ON CONFLICT REPLACE);"); + } catch (SQLException e) { + Log.e(TAG, "Error creating database", e); + throw e; + } + } @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)); + } + + } 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)); + if (NULL_STRING.equals(name)) name = null; if (NULL_STRING.equals(pairedBtAddress)) pairedBtAddress = null; - return new ConnectionConfiguration(name, pairedBtAddress, connectionType, role, enabled > 0, nodeId); + + return new ConnectionConfiguration(name, pairedBtAddress, connectionType, role, enabled > 0, nodeId, packageName); } public ConnectionConfiguration getConfiguration(String name) { @@ -77,45 +191,56 @@ 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); + 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/DataItemRecord.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/DataItemRecord.java index 06052128c0..2fec7c38cb 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,18 @@ 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) { + 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))); + } } + cursor.moveToPrevious(); } - cursor.moveToPrevious(); + } else { + Log.w("DataItemRecord", "Cursor missing asset columns (11,12), skipping asset loading"); } return record; } @@ -164,7 +168,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/MessageHandler.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java index 0f12d92edd..dbd7ac4a69 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,7 +16,14 @@ 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 android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; import android.text.TextUtils; import android.util.Log; @@ -24,44 +31,46 @@ import com.google.android.gms.wearable.ConnectionConfiguration; 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.proto.AckAsset; +import org.microg.gms.wearable.proto.ControlMessage; +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.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 String peerNodeId; + private final ConnectionConfiguration config; - 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; } @@ -69,6 +78,37 @@ private MessageHandler(WearableImpl wearable, ConnectionConfiguration config, St public void onConnect(Connect connect) { super.onConnect(connect); peerNodeId = connect.id; + + if (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 (config.role == ROLE_SERVER) { + wearable.getClockworkNodePreferences().setPeerNodeId(peerNodeId); + } + wearable.onConnectReceived(getConnection(), oldConfigNodeId, connect); try { getConnection().writeMessage(new RootMessage.Builder().syncStart(new SyncStart.Builder() @@ -143,23 +183,24 @@ public void onSetDataItem(SetDataItem setDataItem) { 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; + int requestId = rpcRequest.requestId != null ? rpcRequest.requestId : 0; + String path = rpcRequest.path; + byte[] data = rpcRequest.rawData != null ? rpcRequest.rawData.toByteArray() : null; + String sourceNodeId = TextUtils.isEmpty(rpcRequest.sourceNodeId) ? peerNodeId : rpcRequest.sourceNodeId; + + MessageEventParcelable messageEvent = new MessageEventParcelable(requestId, path, data, sourceNodeId); - wearable.sendMessageReceived(rpcRequest.packageName, messageEvent); + sendMessageReceived(rpcRequest.packageName, messageEvent); } else if (rpcRequest.targetNodeId.equals(peerNodeId)) { // Drop it } else { // TODO: find next hop } - try { - getConnection().writeMessage(new RootMessage.Builder().heartbeat(new Heartbeat()).build()); - } catch (IOException e) { - onDisconnected(); - } + } + + @Override + public void onRpcWithResponseId(Request rpcWithResponseId) { + } @Override @@ -170,11 +211,369 @@ 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); } + + @Override + public void onEncryptionHandshake(EncryptionHandshake encryptionHandshake) { + + } + + @Override + public void onControlMessage(ControlMessage 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.syncStart != null) { + handleSyncStart(connection, sourceNodeId, message.syncStart); + } + + if (message.channelRequest != null && wearable.getChannelManager() != null) { + wearable.getChannelManager().onChannelRequestReceived(connection, sourceNodeId, message.channelRequest); + } + + if (message.rpcRequest != null) { + handleRpcRequest(connection, sourceNodeId, message.rpcRequest); + } + + if (message.setDataItem != null) { + handleSetDataItem(connection, sourceNodeId, message.setDataItem); + } + + if (message.filePiece != null) { + 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) { + Log.d(TAG, "Asset acknowledged: " + message.ackAsset.digest); + } + + if (message.fetchAsset != null) { + handleFetchAsset(connection, sourceNodeId, message.fetchAsset); + } + + if (message.setAsset != null) { + handleSetAsset(connection, sourceNodeId, message.setAsset, message.hasAsset); + } + } + + private void handleSetAsset(WearableConnection connection, String sourceNodeId, + SetAsset setAsset, Boolean hasAsset) { + Log.d(TAG, "handleSetAsset: digest=" + setAsset.digest + + ", hasAsset=" + hasAsset); + + + boolean hasAppKeys = setAsset.appkeys != null && + setAsset.appkeys.appKeys != null && + !setAsset.appkeys.appKeys.isEmpty(); + + if (!hasAppKeys) { + Log.w(TAG, "SetAsset missing AppKeys for digest: " + setAsset.digest); + } + + if (hasAppKeys) { + for (AppKey appKey : setAsset.appkeys.appKeys) { + wearable.getNodeDatabase().allowAssetAccess( + setAsset.digest, + appKey.packageName, + appKey.signatureDigest + ); + } + } + + boolean assetExistsLocally = wearable.assetFileExists(setAsset.digest); + + if (assetExistsLocally) { + wearable.getNodeDatabase().markAssetAsPresent(setAsset.digest); + wearable.getAssetFetcher().onAssetReceived(setAsset.digest); + Log.d(TAG, "Asset already present locally: " + setAsset.digest); + } else { + if (hasAppKeys) { + AppKey firstKey = setAsset.appkeys.appKeys.get(0); + wearable.getNodeDatabase().markAssetAsMissing( + setAsset.digest, + firstKey.packageName, + firstKey.signatureDigest + ); + } else { + wearable.getNodeDatabase().markAssetAsMissing( + setAsset.digest, + "*", + "*" + ); + } + } + } + + + 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); + + if (syncStart.syncTable != null) { + for (org.microg.gms.wearable.proto.SyncTableEntry entry : syncStart.syncTable) { + Log.d(TAG, " Watch sync state: key=" + entry.key + ", seqId=" + entry.value); + } + } + + try { + List syncTable = new ArrayList<>(); + syncTable.add(new org.microg.gms.wearable.proto.SyncTableEntry.Builder() + .key(wearable.getLocalNodeId()) + .value(wearable.getClockworkNodePreferences().getNextSeqId() - 1) + .build()); + + RootMessage response = new RootMessage.Builder() + .syncStart(new org.microg.gms.wearable.proto.SyncStart.Builder() + .receivedSeqId(syncStart.receivedSeqId) + .syncTable(syncTable) + .version(2) + .build()) + .build(); + + connection.writeMessage(response); + Log.d(TAG, "Sent SyncStart response"); + + if (syncStart.syncTable != null) { + for (org.microg.gms.wearable.proto.SyncTableEntry entry : syncStart.syncTable) { + String nodeId = entry.key; + long theirSeqId = entry.value; + wearable.syncToPeer(sourceNodeId, nodeId, theirSeqId); + } + } + + } catch (IOException e) { + Log.e(TAG, "Failed to respond to syncStart", e); + } + } + + 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); + fos.close(); + } catch (IOException e) { + Log.w(TAG, "Error writing file piece", e); + } + + if (finalPieceDigest == null) { + return; + } + + // 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)) { + Log.w(TAG, "Digest mismatch: expected=" + finalPieceDigest + + ", actual=" + digest + ". Deleting temp file."); + file.delete(); + 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(); + 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); + } + + 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); + + boolean allPresent = true; + for (Asset asset : record.dataItem.getAssets().values()) { + if (!wearable.assetFileExists(asset.getDigest())) { + allPresent = false; + break; + } + } + + if (allPresent && !record.assetsAreReady) { + Log.d(TAG, "All assets now ready for: " + record.dataItem.uri); + + record.assetsAreReady = true; + wearable.getNodeDatabase().updateAssetsReady( + record.dataItem.uri.toString(), true); + + Intent intent = new Intent("com.google.android.gms.wearable.DATA_CHANGED"); + intent.setPackage(record.packageName); + intent.setData(record.dataItem.uri); + wearable.invokeListeners(intent, + listener -> listener.onDataChanged(record.toEventDataHolder())); + } + } + } finally { + cursor.close(); + } + } + } + } catch (IOException e) { + Log.w(TAG, "Error processing final file piece", e); + file.delete(); + } + } + + 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://" + wearable.getLocalNodeId() + "/" + messageEvent.getPath())); + wearable.invokeListeners(intent, listener -> listener.onMessageReceived(messageEvent)); + } + + 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)); + + Connect.Builder b = new Connect.Builder() + .name(Build.MODEL) // TODO: Should be hostname, but seems to be irrelevant + .id(wearable.getLocalNodeId()) + .networkId(config.nodeId) + .peerAndroidId(androidId) + .unknown4(3) + .peerVersion(2) + .peerMinimumVersion(0) + .androidSdkVersion(Build.VERSION.SDK_INT); + + if (config.migrating && config.role == ROLE_SERVER) { + String prevPeerNodeId = wearable.getClockworkNodePreferences().getPeerNodeId(); + b.migrating(true); + if (prevPeerNodeId != null) { + Log.i(TAG, "Migration handshake: migratingFromNodeId=" + prevPeerNodeId); + b.migratingFromNodeId(prevPeerNodeId); + } else { + Log.w(TAG, "Migration requested but no previous peer nodeId stored"); + } + } + + return b.build(); + } + } 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..0ea16fce18 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 @@ -35,9 +35,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 +46,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 +233,50 @@ 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 " + + "FROM dataitems d " + + "JOIN appkeys a ON d.appkeys_id = a._id " + + "WHERE " + selection; + + return db.rawQuery(query, params); } public synchronized Cursor getDataItemsByHostAndPath(String packageName, String signatureDigest, String host, String path) { @@ -99,13 +287,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,15 +305,15 @@ 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); @@ -134,42 +323,110 @@ private static synchronized long getAppKey(SQLiteDatabase db, String packageName 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", record.assetsAreReady ? 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); + } + } + } 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", record.assetsAreReady ? 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 +438,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 +452,71 @@ 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, " + + "'' AS assetname, " + + "'' AS assets_digest, " + + "d.v1SourceNode AS v1SourceNode, " + + "d.v1SeqId AS v1SeqId " + + "FROM dataitems d " + + "JOIN appkeys a ON d.appkeys_id = a._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) { @@ -242,12 +547,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 +571,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 +612,70 @@ 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); } + } 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..6e2ea803a7 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeMigrationController.java @@ -0,0 +1,103 @@ +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.locks.ReentrantReadWriteLock; + +public class NodeMigrationController { + private static final String TAG = "NodeMigrationController"; + 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 NodeMigrationController() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + this.completedNodes = ConcurrentHashMap.newKeySet(); + } else { + this.completedNodes = null; + } + } + + 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.putIfAbsent(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(); + } + } +} \ 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..3f22c2e627 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeMigrationTracker.java @@ -0,0 +1,78 @@ +package org.microg.gms.wearable; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +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 String getMigratingFromNodeId(String nodeId) { + if (migrationMap.containsKey(nodeId)) return migrationMap.get(nodeId); + 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); + db.getWritableDatabase().update(TABLE, cv, + COL_NODE_ID + "=?", new String[]{nodeId}); + 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/ServerMessageListener.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ServerMessageListener.java new file mode 100644 index 0000000000..72c522c93f --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ServerMessageListener.java @@ -0,0 +1,45 @@ +/* + * 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; + + public ServerMessageListener(Connect localConnect) { + this.localConnect = localConnect; + } + + @Override + public void onConnected(WearableConnection connection) { + super.onConnected(connection); + try { + connection.writeMessage(new RootMessage.Builder().connect(localConnect).build()); + } catch (IOException ignored) { + // Will disconnect soon + } + } + + @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..52b2664548 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/SocketConnectionThread.java @@ -0,0 +1,100 @@ +/* + * 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; + +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; + + @Override + public void close() { + 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()) { + SocketWearableConnection connection = new SocketWearableConnection(socket, listener); + setWearableConnection(connection); + connection.run(); + } + } catch (IOException e) { + // quit + } finally { + try { + if (serverSocket != null) serverSocket.close(); + } catch (IOException e) { + } + } + } + }; + } + + 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..7a570f9c05 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/SocketWearableConnection.java @@ -0,0 +1,49 @@ +/* + * 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 = 20 * 1024 * 1024; + 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 > MAX_PIECE_SIZE) { + throw new IOException("Piece size " + len + " exceeded limit of " + MAX_PIECE_SIZE + " bytes."); + } + System.out.println("Reading piece of length " + len); + 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/WearableConnection.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnection.java new file mode 100644 index 0000000000..615e620da5 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnection.java @@ -0,0 +1,159 @@ +/* + * 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.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"; + + private HashMap> piecesQueues = new HashMap>(); + private final Listener listener; + + public WearableConnection(Listener listener) { + this.listener = listener; + } + + 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); + // TODO: cut in pieces + writeMessagePiece(new MessagePiece.Builder() + .data(ByteString.of(bytes)) + .digest(calculateDigest(bytes)) + .thisPiece(1) + .totalPieces(1).build()); + } + + 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; + + @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 interface Listener { + void onConnected(WearableConnection connection); + void onMessage(WearableConnection connection, RootMessage message); + void onDisconnected(); + } +} 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..7f1ce2f215 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,6 +16,7 @@ package org.microg.gms.wearable; +import android.Manifest; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; @@ -29,31 +30,37 @@ 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.MessageOptions; import com.google.android.gms.wearable.Node; +import com.google.android.gms.wearable.internal.ChannelEventParcelable; +import com.google.android.gms.wearable.internal.ChannelParcelable; 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.PutDataRequest; 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.BluetoothClient; +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.proto.AppKey; +import org.microg.gms.wearable.proto.AppKeys; +import org.microg.gms.wearable.proto.Connect; +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; @@ -64,11 +71,10 @@ import java.util.ArrayList; import java.util.Arrays; 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.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import okio.ByteString; @@ -77,14 +83,14 @@ public class WearableImpl { private static final String TAG = "GmsWear"; - private static final int WEAR_TCP_PORT = 5601; + public static final int WEAR_TCP_PORT = 5601; 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 RpcHelper rpcHelper; private SocketConnectionThread sct; private ConnectionConfiguration[] configurations; @@ -93,20 +99,217 @@ public class WearableImpl { private CountDownLatch networkHandlerLock = new CountDownLatch(1); public Handler networkHandler; + private BluetoothClient bluetoothClient; + + 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 volatile ChannelManager channelManager; + private NodeMigrationController migrationController; + private NodeMigrationTracker migrationTracker; + + private static final long ASSET_FETCH_COOLDOWN_MS = 500; + private static final int ASSET_BATCH_SIZE = 10; + private volatile long lastAssetFetchTime = 0; + + private AssetFetcher assetFetcher; + + private NetworkConnectionManager networkManager; + public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, ConfigurationDatabaseHelper configDatabase) { this.context = context; this.nodeDatabase = nodeDatabase; this.configDatabase = configDatabase; this.clockworkNodePreferences = new ClockworkNodePreferences(context); this.rpcHelper = new RpcHelper(context); + this.migrationTracker = new NodeMigrationTracker(nodeDatabase); new Thread(() -> { Looper.prepare(); networkHandler = new Handler(Looper.myLooper()); networkHandlerLock.countDown(); Looper.loop(); }).start(); + + new Thread(() -> { + 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); + } + + 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); + } + + 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"); + intent.setPackage(token.appKey.packageName); + intent.setData(Uri.parse("wear://" + token.nodeId + "/" + path)); + + 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); + ChannelParcelable channel = token.toParcelable(path); + + Intent intent = new Intent("com.google.android.gms.wearable.CHANNEL_EVENT"); + intent.setPackage(token.appKey.packageName); + intent.setData(Uri.parse("wear://" + token.nodeId + "/" + path)); + + 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"); + intent.setPackage(token.appKey.packageName); + intent.setData(Uri.parse("wear://" + token.nodeId + "/" + path)); + + 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"); + intent.setPackage(token.appKey.packageName); + intent.setData(Uri.parse("wear://" + token.nodeId + "/" + path)); + + 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); + } + }); + + } + } + + + 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 String getLocalNodeId() { return clockworkNodePreferences.getLocalNodeId(); } @@ -119,24 +322,35 @@ 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); - } + 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); + 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; } @@ -180,12 +394,6 @@ public File createAssetFile(String digest) { 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 +402,44 @@ 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.equals(nodeId)) return configuration; + } + + 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,7 +449,8 @@ public synchronized ConnectionConfiguration[] getConfigurations() { ConnectionConfiguration[] newConfigurations = configDatabase.getAllConfigurations(); for (ConnectionConfiguration configuration : configurations) { for (ConnectionConfiguration newConfiguration : newConfigurations) { - if (newConfiguration.name.equals(configuration.name)) { + if (newConfiguration.address != null && + newConfiguration.address.equalsIgnoreCase(configuration.address)) { newConfiguration.connected = configuration.connected; newConfiguration.peerNodeId = configuration.peerNodeId; newConfiguration.nodeId = configuration.nodeId; @@ -213,6 +460,31 @@ public synchronized ConnectionConfiguration[] getConfigurations() { } configurations = newConfigurations; } + + // 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]; + if (c.name == null || c.name.isEmpty() || "null".equals(c.name)) { + String fallbackName = (c.address != null) ? c.address : "Unknown"; + configurations[i] = new ConnectionConfiguration( + fallbackName, + c.address, + c.type, + c.role, + c.enabled, + c.nodeId, + c.packageName + ); + configurations[i].connected = c.connected; + configurations[i].peerNodeId = c.peerNodeId; + } + } + + Log.d(TAG, "Configurations reported: " + Arrays.toString(configurations)); return configurations; } @@ -254,10 +526,20 @@ void syncRecordToAll(DataItemRecord record) { } } + public Map getActiveConnections() { + return activeConnections; + } + private boolean syncRecordToPeer(String nodeId, DataItemRecord record) { + 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); @@ -276,25 +558,44 @@ private boolean syncRecordToPeer(String nodeId, DataItemRecord record) { return true; } - private void syncAssetToPeer(String nodeId, DataItemRecord record, Asset asset) throws IOException { + private void syncAssetToPeer(WearableConnection connection, DataItemRecord record, Asset asset) throws IOException { 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); + .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,71 +609,70 @@ 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); - } config.peerNodeId = connect.id; config.connected = true; } } - Log.d(TAG, "Adding connection to list of open connections: " + connection + " with connect " + connect); + 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); + + onPeerConnected(new NodeParcelable(connect.id, connect.name, 0, true)); + + syncToPeer(connect.id, nodeId, getCurrentSeqId(nodeId)); + + try { + networkHandlerLock.await(); + networkHandler.postDelayed(() -> { + if (activeConnections.containsKey(connect.id)) { + fetchMissingAssets(connect.id); + } else { + Log.d(TAG, "Connection closed before asset fetch could start"); } - } - cursor.close(); + }, 5000); + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while scheduling asset fetch", e); } } + 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 (config.nodeId.equals(connect.id)) { @@ -396,7 +696,7 @@ interface ListenerInvoker { void invoke(IWearableListener listener) throws RemoteException; } - private void invokeListeners(@Nullable Intent intent, ListenerInvoker invoker) { + 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; @@ -505,23 +805,59 @@ 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 +866,83 @@ public void deleteConnection(String name) { configurationsUpdated = true; } + public void updateConfiguration(ConnectionConfiguration config) { + Log.d(TAG, "updateConfig: " + config); + configDatabase.putConfiguration(config); + configurationsUpdated = true; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void createConnection(ConnectionConfiguration config) { if (config.nodeId == null) config.nodeId = getLocalNodeId(); + + ConnectionConfiguration existing = getConfigurationByAddress(config.address); + if (existing != null) { + Log.d(TAG, "Config already exists for address " + config.address + ", updating"); + if (config.name != null && !config.name.isEmpty() && !"null".equals(config.name)) { + existing.name = config.name; + } + existing.enabled = config.enabled; + configDatabase.putConfiguration(existing); + configurationsUpdated = true; + return; + } + Log.d(TAG, "putConfig[nyp]: " + 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) { + Log.w(TAG, "BLE not implemented"); + } + + 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) { + Log.w(TAG, "Bluetooth role Server not implemented"); + } + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while handling Bluetooth", e); + } } public int deleteDataItems(Uri uri, String packageName) { @@ -545,14 +953,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; @@ -576,26 +976,35 @@ 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); + 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")); + + 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) { + public int sendMessage(String packageName, String targetNodeId, String path, byte[] data, MessageOptions options) { if (activeConnections.containsKey(targetNodeId)) { WearableConnection connection = activeConnections.get(targetNodeId); RpcHelper.RpcConnectionState state = rpcHelper.useConnectionState(packageName, targetNodeId, path); @@ -609,6 +1018,7 @@ public int sendMessage(String packageName, String targetNodeId, String path, byt .sourceNodeId(getLocalNodeId()) .generation(state.generation) .requestId(state.lastRequestId) + .requiresResponse(true) .build()).build()); } catch (IOException e) { Log.w(TAG, "Error while writing, closing link", e); @@ -621,8 +1031,15 @@ public int sendMessage(String packageName, String targetNodeId, String path, byt return -1; } + public int sendRequest(String packageName, String targetNodeId, String path, byte[] data, MessageOptions options) { + return -1; + } + public void stop() { try { + if (channelManager != null) { + channelManager.stop(); + } this.networkHandlerLock.await(); this.networkHandler.getLooper().quit(); } catch (InterruptedException e) { @@ -639,4 +1056,16 @@ private ListenerInfo(IWearableListener listener, IntentFilter[] filters) { this.filters = filters; } } + + public NodeDatabaseHelper getNodeDatabase() { + return nodeDatabase; + } + + public ClockworkNodePreferences getClockworkNodePreferences() { + return clockworkNodePreferences; + } + + public NodeMigrationController getMigrationController() { + return migrationController; + } } 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..8e512d9a00 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableReader.java @@ -0,0 +1,110 @@ +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.LinkedBlockingDeque; +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; + + public WearableReader(String nodeId, WearableConnection source, + WearableConnection listenerView, WearableConnection.Listener listener) { + this.nodeId = nodeId; + this.source = source; + this.listenerView = listenerView; + this.listener = listener; + } + + 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() { + 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; + + try { + listener.onMessage(listenerView, message); + } catch (Exception e) { + Log.e(TAG, "Error dispatching message from node " + nodeId, e); + } + } + } 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); + try { + listener.onDisconnected(); + } catch (Exception e) { + Log.e(TAG, "Error in onDisconnected() for node " + nodeId); + } + finishedLatch.countDown(); + } + } +} 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 c9f3194ede..e1ebf54e0f 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,19 +16,67 @@ package org.microg.gms.wearable; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.os.Build; import android.os.RemoteException; +import android.util.Log; +import androidx.core.app.NotificationCompat; + +import com.google.android.gms.common.Feature; +import com.google.android.gms.common.internal.ConnectionInfo; import com.google.android.gms.common.internal.GetServiceRequest; import com.google.android.gms.common.internal.IGmsCallbacks; import org.microg.gms.BaseService; import org.microg.gms.common.GmsService; import org.microg.gms.common.PackageUtils; +import org.microg.gms.wearable.core.R; public class WearableService extends BaseService { private WearableImpl wearable; + // All what i found + // for now, just to not spam outdated GMS at my companion + public static final Feature[] FEATURES = new Feature[]{ + new Feature("app_client", 4L), + new Feature("carrier_auth", 1L), + new Feature("wear3_oem_companion", 1L), + new Feature("wear_await_data_sync_complete", 1L), + new Feature("wear_backup_restore", 6L), + new Feature("wear_consent", 2L), + new Feature("wear_consent_recordoptin", 1L), + new Feature("wear_consent_recordoptin_swaadl", 1L), + new Feature("wear_consent_supervised", 2L), + new Feature("wear_get_phone_switching_feature_status", 1L), + new Feature("wear_fast_pair_account_key_sync", 1L), + new Feature("wear_fast_pair_get_account_keys", 1L), + new Feature("wear_fast_pair_get_account_key_by_account", 1L), + new Feature("wear_flush_batched_data", 1L), + new Feature("wear_get_related_configs", 1L), + new Feature("wear_get_node_id", 1L), + new Feature("wear_logging_service", 2L), + new Feature("wear_retry_connection", 1L), + new Feature("wear_set_cloud_sync_setting_by_node", 1L), + new Feature("wear_first_party_consents", 2L), + new Feature("wear_update_config", 1L), + new Feature("wear_update_connection_retry_strategy", 1L), + new Feature("wear_update_delay_config", 1L), + new Feature("wearable_services", 1L), + new Feature("wear_cancel_migration", 1L), + new Feature("wear_customizable_screens", 2L), + new Feature("wear_wifi_immediate_connect", 1L), + new Feature("wear_get_node_active_network_metered", 1L), + new Feature("wear_consents_per_watch", 3L) + }; + + private boolean isForeground = false; + private static final int NOTIFICATION_ID = 1001; + private static final String CHANNEL_ID = "wearable_service"; + public WearableService() { super("GmsWearSvc", GmsService.WEAR); } @@ -36,11 +84,44 @@ 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); + } + } + + public void setConnectionActive(boolean active) { + if (active && !isForeground) { + Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(org.microg.gms.base.core.R.drawable.ic_radio_checked) // or whatever icon + .setContentTitle("Connected to watch") + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build(); + + startForeground(NOTIFICATION_ID, notification); + isForeground = true; + Log.d(TAG, "Started foreground service for active connection"); + } else if (!active && isForeground) { + stopForeground(true); + isForeground = false; + Log.d(TAG, "Stopped foreground service"); + } + } + @Override public void onDestroy() { super.onDestroy(); @@ -50,6 +131,9 @@ public void onDestroy() { @Override public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException { PackageUtils.getAndCheckCallingPackage(this, request.packageName); - callback.onPostInitComplete(0, new WearableServiceImpl(this, wearable, request.packageName), null); + ConnectionInfo connectionInfo = new ConnectionInfo(); + connectionInfo.features = FEATURES; + callback.onPostInitCompleteWithConnectionInfo(0, new WearableServiceImpl(this, wearable, request.packageName), connectionInfo); + } } 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..af6ce6a21b 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,23 +16,48 @@ package org.microg.gms.wearable; +import android.Manifest; 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.Handler; +import android.os.Looper; import android.os.Parcel; import android.os.ParcelFileDescriptor; import android.os.RemoteException; +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.Asset; import com.google.android.gms.wearable.ConnectionConfiguration; +import com.google.android.gms.wearable.MessageOptions; import com.google.android.gms.wearable.internal.*; +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.proto.AppKey; + +import java.io.File; import java.io.FileNotFoundException; +import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; public class WearableServiceImpl extends IWearableService.Stub { @@ -44,6 +69,8 @@ public class WearableServiceImpl extends IWearableService.Stub { private final Handler mainHandler; private final CapabilityManager capabilities; + private final Handler handler = new Handler(Looper.getMainLooper()); + public WearableServiceImpl(Context context, WearableImpl wearable, String packageName) { this.context = context; this.wearable = wearable; @@ -52,6 +79,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,8 +111,10 @@ 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); callbacks.onStatus(Status.SUCCESS); @@ -103,6 +142,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 +152,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 +162,68 @@ public void disableConfig(IWearableCallbacks callbacks, final String name) throw }); } + @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"); + } + + wearable.updateConfiguration(toUpdate); + 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 +292,19 @@ public void run(IWearableCallbacks callbacks) throws RemoteException { @Override public void sendMessage(IWearableCallbacks callbacks, final String targetNodeId, final String path, final byte[] data) throws RemoteException { + Log.d(TAG, "sendMessage: " + targetNodeId + " / " + path + ": " + (data == null ? null : Base64.encodeToString(data, Base64.NO_WRAP))); + 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,6 +322,97 @@ public void run(IWearableCallbacks callbacks) throws RemoteException { }); } + @Override + public void sendRequest(IWearableCallbacks callbacks, final String targetNodeId, final String path, final byte[] data) throws RemoteException { + Log.d(TAG, "sendRequest: " + targetNodeId + " / " + path + ": " + (data == null ? null : Base64.encodeToString(data, Base64.NO_WRAP))); + 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 { + RpcResponse rpcResponse = new RpcResponse(4004, -1, new byte[0]); + try { + rpcResponse.requestId = wearable.sendRequest(packageName, targetNodeId, path, data, options); + if (rpcResponse.requestId == -1) { + rpcResponse.statusCode = 4004; + } + } catch (Exception e) { + rpcResponse.statusCode = 8; + } + mainHandler.post(() -> { + try { + callbacks.onRpcResponse(rpcResponse); + } 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, () -> { + callbacks.onStatus(Status.SUCCESS); + }); + } + @Override public void getFdForAsset(IWearableCallbacks callbacks, final Asset asset) throws RemoteException { Log.d(TAG, "getFdForAsset " + asset); @@ -228,6 +428,9 @@ public void getFdForAsset(IWearableCallbacks callbacks, final Asset asset) throw @Override public void optInCloudSync(IWearableCallbacks callbacks, boolean enable) throws RemoteException { + Log.d(TAG, "unimplemented Method optInCloudSync"); + + // dummy callbacks.onStatus(Status.SUCCESS); } @@ -235,39 +438,174 @@ 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 sendAmsRemoteCommand(IWearableCallbacks callbacks, byte command) throws RemoteException { + Log.d(TAG, "unimplemented Method sendAmsRemoteCommand: " + command); + + postMain(callbacks, () -> { + // return error, because we dont have AMS handling + callbacks.onStatus(new Status(CommonStatusCodes.INTERNAL_ERROR)); + }); } @Override - public void sendRemoteCommand(IWearableCallbacks callbacks, byte b) throws RemoteException { - Log.d(TAG, "unimplemented Method: sendRemoteCommand: " + b); + public void getConsentStatus(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "unimplemented Method: getConsentStatus"); + + // needed proper implementation + this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { + @Override + public void run(IWearableCallbacks callbacks) throws RemoteException { + try { + // get data from Tos activity? idk, + // maybe need some Consent manager or something + ConsentResponse cr = new ConsentResponse( + 0, + true, + false, + false, + false, + null, + wearable.getLocalNodeId(), + System.currentTimeMillis() + ); + callbacks.onConsentResponse(cr); + Log.d(TAG, cr.toString()); + + } catch (Exception e) { + Log.e(TAG, "getConsentStatus exception", e); + callbacks.onConsentResponse(new ConsentResponse( + 13, false, false, false, false, + null, null, null + )); + } + } + }); + } + + @Override + public void addAccountToConsent(IWearableCallbacks callbacks, AddAccountToConsentRequest request) throws RemoteException { + Log.d(TAG, "unimplemented Method addAccountToConsent: " + + "account=" + request.accountName + + ", consent=" + request.consentGranted); + + } + + @Override + public void someBoolUnknown(IWearableCallbacks callbacks) throws RemoteException { + // not sure what it is, but i thinking this is to do something with a certificate verification + 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, "unimplemented Method logCounter: " + + request.counterName + + ", value=" + request.value + + ", increment=" + request.increment); + + postMain(callbacks, () -> { + callbacks.onStatus(new Status(0)); + }); + } + + @Override + public void logEvent(IWearableCallbacks callbacks, LogEventRequest request) throws RemoteException { + Log.d(TAG, "unimplemented Method logEvent: data length=" + + (request.eventData != null ? request.eventData.length : 0)); + + postMain(callbacks, () -> { + callbacks.onStatus(new Status(0)); + }); + } + + @Override + public void logTimer(IWearableCallbacks callbacks, LogTimerRequest request) throws RemoteException { + Log.d(TAG, "unimplemented Method logTimer: " + request.timerName + + ", timestamp=" + request.timestamp); + + postMain(callbacks, () -> { + callbacks.onStatus(new Status(0)); + }); + } + + @Override + public void clearLogs(IWearableCallbacks callbacks) throws RemoteException { + Log.d(TAG, "unimplemented Method clearLogs"); + postMain(callbacks, () -> { + callbacks.onStatus(new Status(0)); + }); } @Override public void getLocalNode(IWearableCallbacks callbacks) throws RemoteException { + ConnectionConfiguration config = wearable.getConfigurationByNodeId(wearable.getLocalNodeId()); postMain(callbacks, () -> { try { - callbacks.onGetLocalNodeResponse(new GetLocalNodeResponse(0, new NodeParcelable(wearable.getLocalNodeId(), wearable.getLocalNodeId()))); + callbacks.onGetLocalNodeResponse(new GetLocalNodeResponse(0, new NodeParcelable(config.nodeId, config.name))); } 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) resultNode = configuration.nodeId; + } + + 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, () -> { @@ -281,41 +619,148 @@ public void getConnectedNodes(IWearableCallbacks callbacks) throws RemoteExcepti @Override public void getConnectedCapability(IWearableCallbacks callbacks, String capability, int nodeFilter) throws RemoteException { - Log.d(TAG, "unimplemented Method: getConnectedCapability " + capability + ", " + nodeFilter); + Log.d(TAG, "getConnectedCapability: " + capability + ", nodeFilter=" + nodeFilter); postMain(callbacks, () -> { - List nodes = new ArrayList<>(); - for (String host : capabilities.getNodesForCapability(capability)) { - nodes.add(new NodeParcelable(host, host)); + try { + List nodes = new ArrayList<>(); + Set nodeIds = capabilities.getNodesForCapability(capability); + + for (String nodeId : nodeIds) { + if (shouldIncludeNode(nodeId, nodeFilter)) { + String dispName = wearable.getConfigurationByNodeId(nodeId).name; + 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)); } - 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()); +// Log.d(TAG, "unimplemented Method: getConnectedCapaibilties: " + nodeFilter); +// callbacks.onGetAllCapabilitiesResponse(new GetAllCapabilitiesResponse()); + + Log.d(TAG, "getAllCapabilities: nodeFilter=" + nodeFilter); + postMain(callbacks, () -> { + try { + Map capabilitiesMap = new HashMap<>(); + + 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)){ + String dispName = wearable.getConfigurationByNodeId(nodeId).name; + 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 boolean shouldIncludeNode(String nodeId, int nodeFilter) { + switch (nodeFilter) { + case 0: + return true; + case 1: + case 2: + ConnectionConfiguration[] configs = wearable.getConfigurations(); + if (configs != null) { + for (ConnectionConfiguration config: configs) { + if (nodeId.equals(config.nodeId) && config.connected) { + return true; + } + } + } + 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, "unimplemented Method: addLocalCapability: " + capability); +// Log.d(TAG, "unimplemented Method: addLocalCapability: " + capability); + Log.d(TAG, "addLocalCapability: " + capability); + this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { @Override public void run(IWearableCallbacks callbacks) throws RemoteException { - callbacks.onAddLocalCapabilityResponse(new AddLocalCapabilityResponse(capabilities.add(capability))); + 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, "unimplemented Method: removeLocalCapability: " + capability); +// Log.d(TAG, "unimplemented Method: removeLocalCapability: " + capability); + Log.d(TAG, "removeLocalCapability: " + capability); + this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { @Override public void run(IWearableCallbacks callbacks) throws RemoteException { - callbacks.onRemoveLocalCapabilityResponse(new RemoveLocalCapabilityResponse(capabilities.remove(capability))); + 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)); + } } }); } @@ -336,12 +781,184 @@ public void removeListener(IWearableCallbacks callbacks, RemoveListenerRequest r @Override public void getStorageInformation(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: getStorageInformation"); + 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); + } + } + }); + } + + 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, "unimplemented Method: clearStorage"); + 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 @@ -378,49 +995,292 @@ public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws Rem Log.d(TAG, "unimplemented Method: doAncsNegativeAction: " + i); } - @Override - public void openChannel(IWearableCallbacks callbacks, String s1, String s2) throws RemoteException { - Log.d(TAG, "unimplemented Method: openChannel; " + s1 + ", " + s2); - } - /* * 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 { - Log.d(TAG, "unimplemented Method: closeChannel: " + s); + closeChannelWithError(callbacks, s, 0); } @Override - public void closeChannelWithError(IWearableCallbacks callbacks, String s, int errorCode) throws RemoteException { - Log.d(TAG, "unimplemented Method: closeChannelWithError:" + s + ", " + errorCode); + 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 s) throws RemoteException { - Log.d(TAG, "unimplemented Method: getChannelInputStream: " + s); + 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)); + + } 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 s) throws RemoteException { - Log.d(TAG, "unimplemented Method: getChannelOutputStream: " + s); + 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)); + + } 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 s, ParcelFileDescriptor fd) throws RemoteException { - Log.d(TAG, "unimplemented Method: writeChannelInputToFd: " + s); + 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 s, ParcelFileDescriptor fd, long l1, long l2) throws RemoteException { - Log.d(TAG, "unimplemented Method: readChannelOutputFromFd: " + s + ", " + l1 + ", " + l2); + 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); + } } @Override public void syncWifiCredentials(IWearableCallbacks callbacks) throws RemoteException { Log.d(TAG, "unimplemented Method: syncWifiCredentials"); + + postMain(callbacks, () -> { + // dummy stuff + callbacks.onStatus(new Status(0)); + }); } /* @@ -447,6 +1307,7 @@ public void getConnection(IWearableCallbacks callbacks) throws RemoteException { }); } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) @Override @Deprecated public void enableConnection(IWearableCallbacks callbacks) throws RemoteException { @@ -458,6 +1319,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/WearableWriter.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableWriter.java new file mode 100644 index 0000000000..01bf0c135c --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableWriter.java @@ -0,0 +1,108 @@ +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.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; + + 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 void enqueue(RootMessage message) { + if (!closed.get()) { + queue.offer(message); + } + } + + 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; + + 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..0d21549a68 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BleConnectionManager.java @@ -0,0 +1,675 @@ +package org.microg.gms.wearable.bluetooth; + +import android.bluetooth.BluetoothAdapter; +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; + + public final Context context; + public final BluetoothAdapter btAdapter; + + public final BleServicesHandler gattHelper; + final BluetoothGattHelper bleConnHelper; + + public volatile AtomicReference config; + + final AtomicBoolean isReceiverRegistered; + + 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(); + + public final AtomicInteger scanAttemptCount; + public final AtomicInteger totalExceptionCount; + public final AtomicInteger disconnectExceptionCount; + + 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; + + private final BleScanner bleScanner; + + private static final int ERROR_SAMPLER_BUF_SIZE = 300; + private static final int SERVICE_CHANGED_SAMPLER_BUF_SIZE = 50; + private final AtomicBoolean isBtlePrioEnabled; + private final AtomicLong errorSampler = new AtomicLong(); + + public BleConnectionManager( + Context context, + BluetoothAdapter bluetoothAdapter, + BleScanner bleScanner, + BluetoothGattHelper bleConnHelper, + BleServicesHandler gattHelper, + Looper looper, + ConnectionConfiguration connectionConfiguration) { + super("BleConnectionManager", looper); + + this.isBtlePrioEnabled = new AtomicBoolean(true); + this.config = new AtomicReference<>(); + this.isReceiverRegistered = new AtomicBoolean(false); + this.totalExceptionCount = new AtomicInteger(); + this.disconnectExceptionCount = new AtomicInteger(); + this.scanAttemptCount = new AtomicInteger(); + + 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.context = context; + this.btAdapter = bluetoothAdapter; + this.bleScanner = bleScanner; + this.bleConnHelper = bleConnHelper; + this.gattHelper = gattHelper; + + bleConnHelper.setGattEventListener(this); + + this.config.set(connectionConfiguration); + this.isBtlePrioEnabled.set( + connectionConfiguration == null || connectionConfiguration.btlePriority); + + 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"; + 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 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(); + disconnectGatt(); + disconnectGatt(); // twice in original + 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(); + Log.d(TAG, "Stopped scan."); + removeMessages(MSG_START_SCAN); + removeMessages(MSG_STOP_SCAN); + removeMessages(MSG_START_FORCED_SCAN); + } + + private void disconnectGatt() { + 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) return; + if (!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) { + return true; + } + } + + 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) { + return true; + } + } + + 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(); + } + if (Build.VERSION.SDK_INT >= 28) { + 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, "Failed to update current time"); + mgr.handleBleException(e); + } + Log.d(TAG, "Companion app reset connection after service changed, returning."); + + Log.w(TAG, "Failed to setup Companion app connection. Disconnecting."); + mgr.transitionTo(mgr.disconnectingState); + } + 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) { + return true; + } + } + + 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) { + return true; + } + } + + 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) { + 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/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/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/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..19aaa45c2f --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothClient.java @@ -0,0 +1,281 @@ +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.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 Map configurations = new HashMap<>(); + private final Map connections = new HashMap<>(); + + 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) { + Log.d(TAG, "ACL_CONNECTED for configured device " + address + + ", attempting reconnection"); + + BluetoothConnectionThread thread = connections.get(address); + if (thread != null) { + thread.retryConnection(); + } else { + startConnection(config); + } + } + } + } + + 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..c05019db91 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothConnectionThread.java @@ -0,0 +1,524 @@ +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.PowerManager; +import android.os.SystemClock; +import android.util.Log; + +import androidx.annotation.RequiresPermission; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.MessageHandler; +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(); + + wearableConnection = new BluetoothWearableConnection( + socket, config.nodeId, + new ConnectionListener(context, config, wearableImpl, this) + ); + wearableConnection.run(); + } + + @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; + + long delayMs = retryStrategy.nextDelayMs(); + + if (delayMs < 0) { + Log.d(TAG, "Retry strategy OFF, waiting for external trigger"); + waitForExternalRetry(); + return; + } + + if (immediateRetry.getAndSet(false)) { + Log.d(TAG, "Immediate retry requested"); + wakeLockManager.acquire("retry", delayMs + 5000); + 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(); + } + + private static class ConnectionListener implements WearableConnection.Listener { + private final Context context; + private final ConnectionConfiguration config; + private final WearableImpl wearableImpl; + private Connect peerConnect; + private WearableConnection connection; + + private final BluetoothConnectionThread thread; + + private MessageHandler messageHandler; + + public ConnectionListener(Context context, ConnectionConfiguration config, WearableImpl wearableImpl, BluetoothConnectionThread thread) { + this.context = context; + this.config = config; + this.wearableImpl = wearableImpl; + this.thread = thread; + } + + @Override + public void onConnected(WearableConnection connection) { + Log.d(TAG, "Wearable connection established for " + config.address); + thread.markActivity(); + thread.isConnected = true; + + this.connection = connection; + + BluetoothWearableConnection btConnection = (BluetoothWearableConnection) connection; + this.peerConnect = btConnection.getPeerConnect(); + + this.messageHandler = new MessageHandler(context, wearableImpl, config); + + wearableImpl.onConnectReceived(connection, config.nodeId, peerConnect); + } + + @Override + public void onMessage(WearableConnection connection, RootMessage message) { + Log.d(TAG, "Message received from " + config.address + ": " + message.toString()); + thread.markActivity(); + + if (peerConnect != null && messageHandler != null) + messageHandler.handleMessage(connection, peerConnect.id, message); + } + + @Override + public void onDisconnected() { + Log.d(TAG, "Wearable connection disconnected for " + config.address); + thread.isConnected = false; + if (connection != null && peerConnect != null) { + wearableImpl.onDisconnectReceived(connection, peerConnect); + } + } + } +} 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..fb21e12808 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothGattHelper.java @@ -0,0 +1,10 @@ +package org.microg.gms.wearable.bluetooth; + +public interface BluetoothGattHelper { + boolean isConnected(); + + void disconnect() throws BleException; + void discoverServices() throws BleException; + void refreshGatt(); + void setGattEventListener(GattEventListener listener); +} 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..c6b9e342cf --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothServer.java @@ -0,0 +1,340 @@ +/* + * 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.util.Log; + +import androidx.annotation.RequiresPermission; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +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 BluetoothAdapter bluetoothAdapter; + private final Map servers = new HashMap<>(); + private final BroadcastReceiver bluetoothStateReceiver; + + public BluetoothServer(Context context) { + this.context = context; + this.bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + + 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, 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 int retryCount = 0; + + public BluetoothServerThread(Context context, ConnectionConfiguration config, BluetoothAdapter adapter) { + super("BtServerThread-" + (config.name != null ? config.name : "default")); + this.context = context; + 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()); + + BluetoothWearableConnection connection = new BluetoothWearableConnection( + clientSocket, config.nodeId, new ServerConnectionListener(context, config, clientSocket)); + connection.run(); // Blocks until connection closes + + } catch (IOException e) { + Log.w(TAG, "Error handling connection: " + e.getMessage()); + } finally { + try { + clientSocket.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing client socket", e); + } + } + }, "BtServerConn-" + clientSocket.getRemoteDevice().getAddress()).start(); + } + + 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..4321f33099 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothWearableConnection.java @@ -0,0 +1,362 @@ +/* + * 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.Looper; +import android.util.Log; + +import org.microg.gms.profile.Build; +import org.microg.gms.wearable.WearableConnection; +import org.microg.gms.wearable.proto.Connect; +import org.microg.gms.wearable.proto.Heartbeat; +import org.microg.gms.wearable.proto.MessagePiece; +import org.microg.gms.wearable.proto.RootMessage; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicBoolean; + +public class BluetoothWearableConnection extends WearableConnection { + private static final String TAG = "BtWearableConnection"; + private final int MAX_PIECE_SIZE = 64 * 1024 * 1024; + private final BluetoothSocket socket; + private final DataInputStream is; + private final DataOutputStream os; + private final Listener listener; + + private final String localNodeId; + private String peerNodeId; + private boolean handshakeComplete = false; + private Connect peerConnect; + + private final AtomicBoolean isClosed = new AtomicBoolean(false); + private volatile Thread readerThread; + + private final Handler watchdogHandler; + private static final long READ_TIMEOUT_MS = 60000; + private static final long HANDSHAKE_TIMEOUT_MS = 30000; + + private static final long HEARTBEAT_INTERVAL_MS = 20000; + private volatile boolean heartbeatEnabled = false; + private Thread heartbeatThread; + + public BluetoothWearableConnection(BluetoothSocket socket, String localNodeId, Listener listener) throws IOException { + super(listener); + this.socket = socket; + this.is = new DataInputStream(socket.getInputStream()); + this.os = new DataOutputStream(socket.getOutputStream()); + this.localNodeId = localNodeId; + this.listener = listener; + this.watchdogHandler = new Handler(Looper.getMainLooper()); + + if (localNodeId == null) { + throw new IllegalArgumentException("localNodeId cannot be null"); + } + } + + private 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 connectMessage = new Connect.Builder() + .id(localNodeId) + .name(Build.MODEL) + .peerVersion(2) + .peerMinimumVersion(0) + .build(); + + RootMessage outgoingMessage = new RootMessage.Builder() + .connect(connectMessage) + .build(); + + writeMessage(outgoingMessage); + Log.d(TAG, "Sent Connect message with node ID: " + localNodeId); + + if (isClosed.get() || timedOut.get()) { + Log.w(TAG, "Connection closed before receiving handshake response"); + return false; + } + + RootMessage incomingMessage = readMessage(); + + if (incomingMessage == null) { + Log.e(TAG, "Received null message during handshake"); + return false; + } + + if (timedOut.get()) { + Log.e(TAG, "Handshake completed but timeout already triggered"); + return false; + } + + if (incomingMessage.connect == null) { + Log.e(TAG, "Expected Connect message but received: " + incomingMessage); + return false; + } + + this.peerConnect = incomingMessage.connect; + this.peerNodeId = peerConnect.id; + + if (peerNodeId == null || peerNodeId.isEmpty()) { + Log.e(TAG, "Received invalid peer node ID"); + return false; + } + + Log.d(TAG, "Handshake successful! Peer node ID: " + peerNodeId); + 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 { + if (isClosed.get()) { + throw new IOException("Socket not connected"); + } + + byte[] bytes = MessagePiece.ADAPTER.encode(piece); + + synchronized (os) { + 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; + } + } + } + + @Override + public void run() { + readerThread = Thread.currentThread(); + + startHeartbeat(); + + 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 { + stopHeartbeat(); + readerThread = null; + } + } + + private void startHeartbeat() { + heartbeatEnabled = true; + heartbeatThread = new Thread(() -> { + Log.d(TAG, "Heartbeat thread started for peer: " + peerNodeId); + while (heartbeatEnabled && !isClosed()) { + try { + Thread.sleep(HEARTBEAT_INTERVAL_MS); + + if (heartbeatEnabled && !isClosed()) { + Log.d(TAG, "Sending heartbeat to " + peerNodeId); + writeMessage(new RootMessage.Builder() + .heartbeat(new Heartbeat()) + .build()); + } + } catch (InterruptedException e) { + Log.d(TAG, "Heartbeat thread interrupted"); + break; + } catch (IOException e) { + Log.w(TAG, "Failed to send heartbeat, closing connection", e); + try { + close(); + } catch (IOException ex) { + Log.w(TAG, "Error closing connection", ex); + } + break; + } + } + Log.d(TAG, "Heartbeat thread stopped for peer: " + peerNodeId); + }, "BtHeartbeat-" + peerNodeId); + + heartbeatThread.setDaemon(true); + heartbeatThread.start(); + } + + private void stopHeartbeat() { + heartbeatEnabled = false; + if (heartbeatThread != null) { + heartbeatThread.interrupt(); + try { + heartbeatThread.join(1000); + } catch (InterruptedException e) { + // Ignore + } + heartbeatThread = null; + } + } + + protected MessagePiece readMessagePiece() throws IOException { + if (isClosed.get()) { + throw new IOException("Socket not connected"); + } + + final AtomicBoolean timedOut = new AtomicBoolean(false); + final Thread currentThread = Thread.currentThread(); + + Runnable readWatchdog = () -> { + Log.e(TAG, "Read operation timed out after " + READ_TIMEOUT_MS + "ms"); + timedOut.set(true); + try { + socket.close(); + } catch (IOException e) { + Log.w(TAG, "Error closing socket on read timeout", e); + } + currentThread.interrupt(); + }; + + watchdogHandler.postDelayed(readWatchdog, READ_TIMEOUT_MS); + + 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); + } + + watchdogHandler.removeCallbacks(readWatchdog); + return MessagePiece.ADAPTER.decode(bytes); + } catch (IOException e) { + watchdogHandler.removeCallbacks(readWatchdog); + + 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"); + + stopHeartbeat(); + + 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 Connect getPeerConnect() { + return peerConnect; + } + + public boolean isClosed() { + return isClosed.get(); + } + +} \ 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..11a21a53ef --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/GattEventListener.java @@ -0,0 +1,10 @@ +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(); +} 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..75d332ede0 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelManager.java @@ -0,0 +1,576 @@ +package org.microg.gms.wearable.channel; + +import android.os.Build; +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.Arrays; +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; + + 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 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); + } + + 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 { + + 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); + return; + } + + 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(); + } + + + 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) { + throw new ChannelException(token, "No callback on timeout"); + } + ch.openResultDispatcher.onResult( + ChannelStatusCodes.CLOSE_REASON_REMOTE_CLOSE, null, ch.channelPath); + ch.openResultDispatcher = null; + } + }); + } + + 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 { + 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(0L) + .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(0L) + .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(0) + .origin(0) + .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()) + .build(); + + return new RootMessage.Builder().channelRequest(req).build(); + } + + private RootMessage buildDataRootMessage(ChannelStateMachine ch, ChannelDataRequest dataReq) { + ChannelRequest cr = new ChannelRequest.Builder() + .channelDataRequest(dataReq).version(1).origin(0).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) + .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(1).origin(0).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) + .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 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..84574dc028 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelStateMachine.java @@ -0,0 +1,743 @@ +package org.microg.gms.wearable.channel; + +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import android.util.Log; + +import com.google.android.gms.wearable.internal.IChannelStreamCallbacks; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +public class ChannelStateMachine { + private static final String TAG = "ChannelStateMachine"; + + private static final int DEFAULT_CHUNK_SIZE = 8192; + private static final int MAX_CHUNK_SIZE = 65536; + private static final int RECEIVE_BUFFER_SIZE = 65536; + + 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; + public ByteBuffer receiveBuffer; + public boolean receivePending = false; + + 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; + + 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 bytesRead = transport.read(outputFd, sendBuffer.array(), + sendBuffer.position(), sendBuffer.remaining()); + + if (bytesRead > 0) { + sendBuffer.position(sendBuffer.position() + bytesRead); + } + + sendBuffer.flip(); + + if (sendMaxLength >= 0) { + sendMaxLength -= sendBuffer.remaining(); + } + + byte[] data = new byte[sendBuffer.remaining()]; + sendBuffer.get(data); + + boolean isFinal = (bytesRead < 0) || (sendMaxLength == 0); + + 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::onSendTimeout, 30000, "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 onSendTimeout() { + Log.w(TAG, "Sending data timed out. Closing channel"); + sendPendingOp = null; + + try { + onChannelOutputClosed(ChannelStatusCodes.CLOSE_REASON_REMOTE_CLOSE, 0); + onChannelInputClosed(ChannelStatusCodes.CLOSE_REASON_REMOTE_CLOSE, 0); + sendCloseRequest(0); + channelManager.removeChannel(token); + } catch (IOException e) { + Log.e(TAG, "Error handling send timeout", e); + } + } + + 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) { + Log.d(TAG, "Received final ACK, closing output"); + setSendingState(SENDING_STATE_CLOSED); + + try { + onChannelOutputClosed(ChannelStatusCodes.CLOSE_REASON_NORMAL, 0); + } catch (IOException e) { + Log.e(TAG, "Error closing output after final ACK", e); + } + } else { + setSendingState(SENDING_STATE_WAITING_TO_READ); + } + } + + public void onDataReceived(byte[] data, boolean isFinal, long offset) throws ChannelException { + synchronized (stateLock) { + if (connectionState != CONNECTION_STATE_ESTABLISHED && + connectionState != CONNECTION_STATE_CLOSING) { + throw new ChannelException(token, "Received data in wrong connection state: " + + getConnectionStateString(connectionState)); + } + } + + switch (receivingState) { + case RECEIVING_STATE_WAITING_FOR_DATA: + if (offset != lastAckedOffset) { + Log.w(TAG, "Received data with wrong offset: expected=" + + lastAckedOffset + ", got=" + offset); + return; + } + + if (data.length > MAX_CHUNK_SIZE) { + Log.w(TAG, "Received payload longer than max buffer size"); + throw new ChannelException(token, "Payload too large"); + } + + if (receiveBuffer == null) { + receiveBuffer = ByteBuffer.allocate(RECEIVE_BUFFER_SIZE); + } + + receiveBuffer.clear(); + receiveBuffer.put(data); + receiveBuffer.flip(); + + if (isFinal) { + receivePending = true; + } + + if (inputFd != null) { + transport.setMode(inputFd, ChannelTransport.IOMode.WRITE); + } + + setReceivingState(RECEIVING_STATE_WAITING_TO_WRITE); + break; + + case RECEIVING_STATE_WAITING_TO_WRITE: + if (offset <= lastAckedOffset) { + Log.d(TAG, "Ignoring duplicate data packet"); + return; + } + + Log.w(TAG, "Received new data before ACK of last one"); + throw new ChannelException(token, "Data received before ACK"); + + case RECEIVING_STATE_CLOSED: + throw new ChannelException(token, "Received data after close"); + } + } + + public void processIncomingBuffer() throws IOException { + if (receivingState != RECEIVING_STATE_WAITING_TO_WRITE) { + return; + } + + if (inputFd == null || receiveBuffer == null) { + return; + } + + if (receiveBuffer.hasRemaining()) { + int toWrite = receiveBuffer.remaining(); + int written = transport.write(inputFd, receiveBuffer.array(), + receiveBuffer.position(), toWrite); + + if (written > 0) { + receiveBuffer.position(receiveBuffer.position() + written); + } + } + + if (!receiveBuffer.hasRemaining()) { + lastAckedOffset += receiveBuffer.capacity(); + + channelManager.sendDataAck(this, lastAckedOffset, receivePending); + + if (receivePending) { + onChannelInputClosed(ChannelStatusCodes.CLOSE_REASON_NORMAL, 0); + } else { + transport.setMode(inputFd, ChannelTransport.IOMode.NONE); + setReceivingState(RECEIVING_STATE_WAITING_FOR_DATA); + } + } + } + + 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, ChannelException { + 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); + throw new ChannelException(token, "Remote close"); + } 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 = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CUPCAKE) { + 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 { + synchronized (stateLock) { + if (connectionState == CONNECTION_STATE_CLOSED) return; + this.connectionState = CONNECTION_STATE_CLOSED; + } + + 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; + } + } + 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); + receiveBuffer = null; + 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) { + 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 + ")"; + } + } + + @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..661e9f4e9a --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTable.java @@ -0,0 +1,62 @@ +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 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..1be51bdef4 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelToken.java @@ -0,0 +1,146 @@ +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"); + } + + return new ChannelToken( + proto.nodeId, + tokenAppKey, + 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..a01adf2f1c --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/ChannelTransport.java @@ -0,0 +1,92 @@ +package org.microg.gms.wearable.channel; + +import android.os.Build; +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; + +public class ChannelTransport { + private static final String TAG = "ChannelTransport"; + + private final Map fdModes = new ConcurrentHashMap<>(); + + public enum IOMode { + NONE, + READ, + WRITE + } + + public void register(ParcelFileDescriptor fd) { + if (fd != null) { + fdModes.put(fd, IOMode.NONE); + Log.d(TAG, "Registered FD: " + fd); + } + } + + public void unregister(ParcelFileDescriptor fd) { + if (fd != null) { + fdModes.remove(fd); + 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"); + } + + try { + FileInputStream fis = new FileInputStream(fd.getFileDescriptor()); + int bytesRead = fis.read(buf, offset, len); + + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "Read " + bytesRead + " bytes from FD"); + } + + return bytesRead; + + } catch (EOFException e) { + return -1; + } + } + + 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 = new FileOutputStream(fd.getFileDescriptor()); + 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(); + } +} \ 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..8a3cb7303d --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelControlTask.java @@ -0,0 +1,350 @@ +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"); + } + + AppKey rawKey = new AppKey(control.packageName, control.signatureDigest); + 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, true); + + 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); + + ChannelStateMachine channel = channelManager.channelTable.get( + sourceNodeId, control.channelId, !control.fromChannelOperator); + + if (channel == null) { + Log.w(TAG, "Received close for unknown channel: " + control.channelId); + return; + } + + setChannel(channel); + + try { + int errorCode = control.closeErrorCode; + channel.onRemoteCloseReceived(errorCode); + Log.d(TAG, "Channel closed by remote: " + channel.token); + } catch (ChannelException e) { + Log.e(TAG, "Error handling remote close", e); + throw e; + } finally { + 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..13d302080b --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/channel/OnChannelDataTask.java @@ -0,0 +1,51 @@ +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; + + channel.onDataReceived(data, isFinal, requestId); + } +} \ 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..a821a6a692 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/network/NetworkConnectionThread.java @@ -0,0 +1,273 @@ +package org.microg.gms.wearable.network; + +import android.content.Context; +import android.util.Log; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.MessageHandler; +import org.microg.gms.wearable.SocketWearableConnection; +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.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); + MessageHandler msgHandler = new MessageHandler(context, wearable, config); + WearableWriter writer = new WearableWriter(config.address, raw); + QueueingConnection facade = new QueueingConnection(writer); + WearableReader reader = new WearableReader(config.address, raw, facade, msgHandler); + + activeWriter = writer; + + writer.start(); + reader.start(); + + reader.awaitFinished(); + + writer.close(); + writer.awaitFinished(); + + try { + raw.close(); + } catch (IOException ignore) {} + + activeWriter = null; + } + + 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; + } + } + + private static final class QueueingConnection extends WearableConnection { + private static final Listener NO_OP = new Listener() { + @Override + public void onConnected(WearableConnection connection) {} + + @Override + public void onMessage(WearableConnection connection, RootMessage message) {} + + @Override + public void onDisconnected() {} + }; + + private final WearableWriter writer; + + QueueingConnection(WearableWriter writer) { + super(NO_OP); + this.writer = writer; + } + + @Override + public void writeMessage(RootMessage message) throws IOException { + writer.enqueue(message); + } + + @Override + protected void writeMessagePiece(MessagePiece piece) throws IOException { + throw new UnsupportedOperationException("write-only facade"); + } + + @Override + protected MessagePiece readMessagePiece() throws IOException { + throw new UnsupportedOperationException("write-only facade"); + } + + @Override + public void close() throws IOException { + writer.close(); + } + } +} 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..75784a2b83 --- /dev/null +++ b/play-services-wearable/core/src/main/proto/wearable.proto @@ -0,0 +1,252 @@ +/* + * 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 { + +} + +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 = 13; + optional int32 senderRequestId = 14; + optional int32 priority = 15; +} + +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; + // This is probably not needed? + // 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; +} 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/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/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/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/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/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/IWearableService.aidl b/play-services-wearable/src/main/aidl/com/google/android/gms/wearable/internal/IWearableService.aidl index 423dc8b3a9..203743aafa 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,25 @@ 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.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.MessageOptions; + 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 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 +39,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 @@ -72,7 +88,23 @@ 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 getConsentStatus(IWearableCallbacks callbacks) = 64; + void addAccountToConsent(IWearableCallbacks callbacks, in AddAccountToConsentRequest request) = 65; + +// 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 // 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/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/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/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/WearableListenerService.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/WearableListenerService.java index 8aa5b091cf..f39ab6c911 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 @@ -157,105 +157,62 @@ 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 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..e04bd60834 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AcceptTermsRequest.java @@ -0,0 +1,54 @@ +/* + * 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 { + @SafeParceled(1) + public final int statusCode; // assuming this is statusCode + @SafeParceled(2) + public final List unk2; + @SafeParceled(3) + public final String unk3; + @SafeParceled(4) + public final String unk4; + @SafeParceled(5) + public final String unk5; + @SafeParceled(6) + public final String unk6; + @SafeParceled(7) + public final List unk7; + @SafeParceled(8) + public final boolean unk8; + + public AcceptTermsRequest(int statusCode, List unk2, String unk3, String unk4, String unk5, String unk6, List unk7, boolean unk8) { + this.statusCode = statusCode; + this.unk2 = unk2; + this.unk3 = unk3; + this.unk4 = unk4; + this.unk5 = unk5; + this.unk6 = unk6; + this.unk7 = unk7; + this.unk8 = unk8; + } + + public static final Creator CREATOR = new AutoCreator(AcceptTermsRequest.class); +} 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/AppRecommendationsResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AppRecommendationsResponse.java new file mode 100644 index 0000000000..af03ef54d0 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/AppRecommendationsResponse.java @@ -0,0 +1,25 @@ +/* + * 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 { + + 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..a32c1d709f --- /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(6) + 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/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..5fac138221 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetAppThemeResponse.java @@ -0,0 +1,25 @@ +/* + * 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 GetAppThemeResponse extends AutoSafeParcelable { + + 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..552b7f974b --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetBackupSettingsSupportedResponse.java @@ -0,0 +1,25 @@ +/* + * 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 { + + 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..fe9f3afca3 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetEapIdResponse.java @@ -0,0 +1,25 @@ +/* + * 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 { + + 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..b024413a11 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetFastpairAccountKeyByAccountResponse.java @@ -0,0 +1,25 @@ +/* + * 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 { + + 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..0c13333419 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetFastpairAccountKeysResponse.java @@ -0,0 +1,25 @@ +/* + * 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 GetFastpairAccountKeysResponse extends AutoSafeParcelable { + + 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/GetRestoreStateResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreStateResponse.java new file mode 100644 index 0000000000..3a590520f9 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreStateResponse.java @@ -0,0 +1,25 @@ +/* + * 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 { + + 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..1c67559fe9 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetRestoreSupportedResponse.java @@ -0,0 +1,25 @@ +/* + * 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 { + + 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..1d1249f17e --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetTermsResponse.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 GetTermsResponse extends AutoSafeParcelable { + @SafeParceled(1) + public int statusCode; + @SafeParceled(2) + 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..cd2030a473 --- /dev/null +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/PerformEapAkaResponse.java @@ -0,0 +1,25 @@ +/* + * 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 { + + public static final Creator CREATOR = new AutoCreator(PerformEapAkaResponse.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..111b94d5c6 --- /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 unk1; + @SafeParceled(2) + public final int unk2; + @SafeParceled(3) + public final boolean unk3; + @SafeParceled(4) + public final String unk4; + @SafeParceled(5) + public final String unk5; + @SafeParceled(6) + public final String unk6; + + public RecordTermConsentRequest(int unk1, int unk2, boolean unk3, String unk4, String unk5, String unk6) { + this.unk1 = unk1; + this.unk2 = unk2; + this.unk3 = unk3; + this.unk4 = unk4; + this.unk5 = unk5; + this.unk6 = unk6; + } + + public static final Creator CREATOR = new AutoCreator(RecordTermConsentRequest.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/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");