diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b8a40270..48427c75 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -619,6 +619,11 @@ class MeshCoreConnector extends ChangeNotifier { List _previousChannelsCache = []; static const int _maxChannelSyncRetries = 3; + /// Gap between consecutive channel writes in [restoreChannelsToRadio], so a + /// multi-channel restore cannot outrun the radio. Matches the spacing the USB + /// branch of [sendFrame] already applies per frame (#1060). + static const Duration _channelRestoreFrameGap = Duration(milliseconds: 10); + // #1062: fail-closed channel sync. A slot the device never answered for is // UNKNOWN: tracked here, surfaced to the UI, and never backfilled from a // previous list (that backfill is how a stale slot map misrouted @@ -2542,7 +2547,24 @@ class MeshCoreConnector extends ChangeNotifier { } Future loadCachedChannels() async { - _cachedChannels = await _channelStore.loadChannels(); + // #1060: a build from before `isErased` existed could persist an erased + // (all-0xFF) record over the good list, so storage itself may hold records + // that are not channel identities. Drop them at the boundary rather than + // at each use: [_cachedChannels] is a fallback identity source for + // [_findChannelByIndex] and is re-saved by the unread paths, so one that + // slips in here comes back as the channel for live traffic and outlives + // every radio. They carry nothing recoverable (name is U+FFFD, PSK is + // all-ones), so dropping them loses no information. + final loaded = await _channelStore.loadChannels(); + final usable = loaded.where((c) => !c.isUnusable).toList(); + if (usable.length != loaded.length) { + _appDebugLogService?.warn( + 'Dropped ${loaded.length - usable.length} unusable channel record(s) ' + 'from storage on load; a pre-#1060 build had persisted erased slots', + tag: 'ChannelSync', + ); + } + _cachedChannels = usable; _recalculateCachedChannelsUnreadTotal(); } @@ -4465,6 +4487,12 @@ class MeshCoreConnector extends ChangeNotifier { void setContactSettingsKeyForTest(String radioKeyHex) => _contactSettingsStore.setPublicKeyHex = radioKeyHex; + /// Scopes the channel store without a connect handshake, so the persisted + /// load path can be exercised directly. (#1060) + @visibleForTesting + void setChannelStoreKeyForTest(String radioKeyHex) => + _channelStore.setPublicKeyHex = radioKeyHex; + /// Feeds a device frame through the normal dispatch, so parse paths can be /// exercised without a radio. (#578) @visibleForTesting @@ -7124,7 +7152,8 @@ class MeshCoreConnector extends ChangeNotifier { /// clears history: the identities being restored are the ones that own it. /// /// Returns the number of channels written, or -1 when there was nothing to - /// restore or the link is down. + /// restore or the link is down. Written is not the same as confirmed: the + /// erased-slot banner is what tells the user the radio took them. Future restoreChannelsToRadio() async { if (!isConnected) return -1; final toRestore = _cachedChannels.where((c) => !c.isUnusable).toList(); @@ -7136,19 +7165,34 @@ class MeshCoreConnector extends ChangeNotifier { tag: 'ChannelSync', ); - var written = 0; + var sent = 0; for (final channel in toRestore) { if (!isConnected) break; await sendFrame( buildSetChannelFrame(channel.index, channel.name, channel.psk), ); - written++; + sent++; + // Space the writes. On BLE [sendFrame] prefers write-without-response, + // which returns once the frame reaches the OS and not once the radio has + // taken it, so an unpaced loop can overrun the radio and lose channels + // with no error anywhere. The USB branch of [sendFrame] already spaces + // its own frames for exactly this reason; BLE has no such pause. + if (sent < toRestore.length) { + await Future.delayed(_channelRestoreFrameGap); + } } // The radio now holds these again, so a fresh sync should confirm them and // the erased-slot block clears itself on the next pass. + // + // This deliberately does NOT report how many the radio confirmed: + // [getChannels] returns once the first request is away, not once the sync + // has finished, so counting confirmations here would read a list the radio + // has not answered for yet and report 0 every time. The authoritative + // feedback is the erased-slot banner, which is re-derived on every sync and + // therefore stays up if some writes did not land. await getChannels(force: true); - return written; + return sent; } Future deleteChannel(int index) async { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 726644ff..67a35e60 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -887,7 +887,7 @@ } } }, - "channels_restoreToRadioDone": "Restored {count} channel(s) to the radio.", + "channels_restoreToRadioDone": "Wrote {count} channel(s) back to the radio. If the warning above stays, some did not take.", "@channels_restoreToRadioDone": { "placeholders": { "count": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7627cf2d..e9143266 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2908,6 +2908,12 @@ abstract class AppLocalizations { /// **'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'** String get channels_restoreToRadioFailed; + /// No description provided for @channels_restoreToRadioPartial. + /// + /// In en, this message translates to: + /// **'Only {written} of {total} channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'** + String channels_restoreToRadioPartial(int written, int total); + /// No description provided for @channels_slotIdentityChanged. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 6e271fb5..71cc4b54 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1640,6 +1640,11 @@ class AppLocalizationsBg extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 00745c5d..2e3c621b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1634,6 +1634,11 @@ class AppLocalizationsDe extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 82f9c71c..61292838 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1616,6 +1616,11 @@ class AppLocalizationsEn extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 92e7cbb2..aae1876e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1635,6 +1635,11 @@ class AppLocalizationsEs extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 2f480d41..35548842 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1640,6 +1640,11 @@ class AppLocalizationsFr extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index e7773d74..a7620c80 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1641,6 +1641,11 @@ class AppLocalizationsHu extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index ed6648b9..e8cd3199 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1636,6 +1636,11 @@ class AppLocalizationsIt extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 4839f452..3e872186 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1584,6 +1584,11 @@ class AppLocalizationsJa extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 005ecd2d..504d09a1 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1579,6 +1579,11 @@ class AppLocalizationsKo extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 211699bf..65e4a765 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1627,6 +1627,11 @@ class AppLocalizationsNl extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 00f50105..db61d00d 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1646,6 +1646,11 @@ class AppLocalizationsPl extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 952b743c..2805a9fb 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1636,6 +1636,11 @@ class AppLocalizationsPt extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 9e83146f..ea40f812 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1637,6 +1637,11 @@ class AppLocalizationsRu extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index a252ae03..2f0c9a09 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1627,6 +1627,11 @@ class AppLocalizationsSk extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index a5a886ac..cabae35c 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1624,6 +1624,11 @@ class AppLocalizationsSl extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index a81ab444..716dbdf2 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1618,6 +1618,11 @@ class AppLocalizationsSv extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8f6b18fc..d3f3f227 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1632,6 +1632,11 @@ class AppLocalizationsUk extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d063a542..a40445dc 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1565,6 +1565,11 @@ class AppLocalizationsZh extends AppLocalizations { String get channels_restoreToRadioFailed => 'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'; + @override + String channels_restoreToRadioPartial(int written, int total) { + return 'Only $written of $total channel(s) came back after the restore. The radio did not take the rest. Try the restore again.'; + } + @override String channels_slotIdentityChanged(String changes) { return 'Channel layout changed on the radio: $changes'; diff --git a/test/connector/channel_erased_slot_test.dart b/test/connector/channel_erased_slot_test.dart index a5cd1e9e..7d5a1cd1 100644 --- a/test/connector/channel_erased_slot_test.dart +++ b/test/connector/channel_erased_slot_test.dart @@ -15,6 +15,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/models/channel.dart'; +import 'package:meshcore_open/storage/channel_store.dart'; import 'package:meshcore_open/storage/drift/blob_store.dart'; import 'package:meshcore_open/storage/drift/offband_database.dart'; import 'package:meshcore_open/storage/prefs_manager.dart'; @@ -244,5 +245,94 @@ void main() { expect(sent.where((f) => f[0] == cmdSetChannel), isEmpty); }, ); + + // Adversarial review finding: on BLE sendFrame prefers write-without- + // response, so its await returns when the frame reaches the OS and not + // when the radio has taken it. An unpaced loop can therefore overrun the + // radio and drop channels with no error raised anywhere. The USB branch + // of sendFrame already spaces its frames; this loop must too. + test( + 'consecutive channel writes are spaced, not fired in a tight loop', + () async { + c.seedCachedChannelsForTest([ + _channel(0, 'Public', 10), + _channel(1, 'wardriving', 40), + _channel(2, 'test', 70), + ]); + sent.clear(); + + final sw = Stopwatch()..start(); + final written = await c.restoreChannelsToRadio(); + sw.stop(); + + expect(written, 3); + // Two gaps between three writes. A lower bound, so this asserts the + // pacing exists without pinning the exact duration. + expect( + sw.elapsedMilliseconds, + greaterThanOrEqualTo(20), + reason: 'three writes with no spacing would return almost instantly', + ); + }, + ); + }); + + // Adversarial review finding: the guards added here stop an erased record + // being adopted from the WIRE, but a build from before they existed could + // already have persisted one. Storage is therefore an untrusted source too, + // and _cachedChannels is a fallback identity source for _findChannelByIndex + // as well as being re-saved by the unread paths, so one that slips in comes + // back as the channel for live traffic and outlives every radio. + group('erased records already sitting in storage', () { + const radioKey = 'aabbccddee'; + + test('are dropped on load, not adopted from the cache', () async { + final store = ChannelStore()..setPublicKeyHex = radioKey; + await store.saveChannels([ + _channel(0, 'Public', 10), + Channel.fromFrame(_erasedFrame(1))!, + _channel(2, 'wardriving', 40), + ]); + // The store itself is faithful: it persisted exactly what it was given, + // which is how the bad record got there in the first place. + expect((await store.loadChannels()).length, 3); + + c.setChannelStoreKeyForTest(radioKey); + await c.loadCachedChannels(); + + expect(c.cachedChannels.map((ch) => ch.name), ['Public', 'wardriving']); + expect(c.cachedChannels.any((ch) => ch.isUnusable), isFalse); + }); + + test('a clean stored list is loaded untouched', () async { + final store = ChannelStore()..setPublicKeyHex = radioKey; + await store.saveChannels([ + _channel(0, 'Public', 10), + _channel(1, 'wardriving', 40), + ]); + + c.setChannelStoreKeyForTest(radioKey); + await c.loadCachedChannels(); + + expect(c.cachedChannels.length, 2); + expect(c.cachedChannels.map((ch) => ch.name), ['Public', 'wardriving']); + }); + + test('an all-erased stored list leaves nothing to restore', () async { + final store = ChannelStore()..setPublicKeyHex = radioKey; + await store.saveChannels([ + Channel.fromFrame(_erasedFrame(0))!, + Channel.fromFrame(_erasedFrame(1))!, + ]); + + c.setChannelStoreKeyForTest(radioKey); + await c.loadCachedChannels(); + + expect(c.cachedChannels, isEmpty); + // Nothing usable cached means the restore is refused rather than + // writing erased records back onto the radio. + expect(await c.restoreChannelsToRadio(), -1); + expect(sent.where((f) => f[0] == cmdSetChannel), isEmpty); + }); }); } diff --git a/untranslated.json b/untranslated.json index b34f1b93..50f1f65d 100644 --- a/untranslated.json +++ b/untranslated.json @@ -141,6 +141,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -503,6 +504,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -865,6 +867,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -1227,6 +1230,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -1589,6 +1593,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -1951,6 +1956,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -2313,6 +2319,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -2675,6 +2682,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -3037,6 +3045,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -3399,6 +3408,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -3761,6 +3771,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -4123,6 +4134,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -4485,6 +4497,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -4847,6 +4860,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -5209,6 +5223,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -5571,6 +5586,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications", @@ -5933,6 +5949,7 @@ "channels_restoreToRadioConfirm", "channels_restoreToRadioDone", "channels_restoreToRadioFailed", + "channels_restoreToRadioPartial", "channels_slotIdentityChanged", "common_dismiss", "channels_notifications",