diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index d80ba5e3f..35e28f3f8 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "بعد الحفظ، أعد الاتصال بجهاز التوجيه على {url}.", "bridgeRedirectTitle": "إعادة الاتصال بجهاز التوجيه", "bridgeRedirectMessage": "يعمل جهاز التوجيه الآن كجسر شفاف ولم يعد يوزّع عناوين IP محلية. أعد الاتصال على {url}.", - "bridgeRedirectButton": "الانتقال إلى جهاز التوجيه" + "bridgeRedirectButton": "الانتقال إلى جهاز التوجيه", + "lanIpRedirectTitle": "إعادة الاتصال بجهاز التوجيه", + "lanIpRedirectMessage": "لقد تغيّر عنوان IP الخاص بجهاز التوجيه. أعد الاتصال به على {url}. قد تحتاج إلى الانتظار قليلاً حتى يحصل جهازك على عنوان جديد.", + "lanIpRedirectButton": "الانتقال إلى جهاز التوجيه" } diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index e5c49c754..0884b6f39 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Efter du har gemt, skal du oprette forbindelse til din router igen på {url}.", "bridgeRedirectTitle": "Opret forbindelse til din router igen", "bridgeRedirectMessage": "Din router fungerer nu som en transparent bro og tildeler ikke længere lokale IP-adresser. Opret forbindelse igen på {url}.", - "bridgeRedirectButton": "Gå til routeren" + "bridgeRedirectButton": "Gå til routeren", + "lanIpRedirectTitle": "Opret forbindelse til din router igen", + "lanIpRedirectMessage": "Din routers IP-adresse er ændret. Opret forbindelse til den igen på {url}. Du skal muligvis vente et øjeblik, før din enhed får en ny adresse.", + "lanIpRedirectButton": "Gå til routeren" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e4e2dcf58..3baee3096 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Verbinden Sie sich nach dem Speichern unter {url} erneut mit Ihrem Router.", "bridgeRedirectTitle": "Erneut mit dem Router verbinden", "bridgeRedirectMessage": "Ihr Router arbeitet jetzt als transparente Bridge und vergibt keine lokalen IP-Adressen mehr. Verbinden Sie sich unter {url} erneut.", - "bridgeRedirectButton": "Zum Router" + "bridgeRedirectButton": "Zum Router", + "lanIpRedirectTitle": "Erneut mit dem Router verbinden", + "lanIpRedirectMessage": "Die IP-Adresse Ihres Routers hat sich geändert. Stellen Sie die Verbindung unter {url} wieder her. Möglicherweise müssen Sie einen Moment warten, bis Ihr Gerät eine neue Adresse erhält.", + "lanIpRedirectButton": "Zum Router" } diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index c24a9c72f..2c01eeb89 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Μετά την αποθήκευση, επανασυνδεθείτε στον δρομολογητή σας στη διεύθυνση {url}.", "bridgeRedirectTitle": "Επανασύνδεση στον δρομολογητή σας", "bridgeRedirectMessage": "Ο δρομολογητής σας λειτουργεί τώρα ως διαφανής γέφυρα και δεν εκχωρεί πλέον τοπικές διευθύνσεις IP. Επανασυνδεθείτε στη διεύθυνση {url}.", - "bridgeRedirectButton": "Μετάβαση στον δρομολογητή" + "bridgeRedirectButton": "Μετάβαση στον δρομολογητή", + "lanIpRedirectTitle": "Επανασύνδεση στον δρομολογητή σας", + "lanIpRedirectMessage": "Η διεύθυνση IP του δρομολογητή σας άλλαξε. Επανασυνδεθείτε σε αυτόν στο {url}. Ίσως χρειαστεί να περιμένετε λίγο μέχρι η συσκευή σας να λάβει νέα διεύθυνση.", + "lanIpRedirectButton": "Μετάβαση στον δρομολογητή" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5ec4edc59..e019253d8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1718,5 +1718,16 @@ } } }, - "bridgeRedirectButton": "Go to router" + "bridgeRedirectButton": "Go to router", + "lanIpRedirectTitle": "Reconnect to your router", + "lanIpRedirectMessage": "Your router's IP address has changed. Reconnect to it at {url}. You may need to wait a moment for your device to obtain a new address.", + "@lanIpRedirectMessage": { + "description": "Body of the dialog shown after changing the router LAN IP address. {url} is https://.local", + "placeholders": { + "url": { + "type": "Object" + } + } + }, + "lanIpRedirectButton": "Go to router" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1bce023a7..9984b0cdb 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Después de guardar, vuelve a conectarte a tu router en {url}.", "bridgeRedirectTitle": "Vuelve a conectarte a tu router", "bridgeRedirectMessage": "Tu router ahora es un puente transparente y ya no asigna direcciones IP locales. Vuelve a conectarte en {url}.", - "bridgeRedirectButton": "Ir al router" + "bridgeRedirectButton": "Ir al router", + "lanIpRedirectTitle": "Vuelve a conectarte a tu router", + "lanIpRedirectMessage": "La dirección IP de tu router ha cambiado. Vuelve a conectarte a él en {url}. Es posible que debas esperar un momento a que tu dispositivo obtenga una nueva dirección.", + "lanIpRedirectButton": "Ir al router" } diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 535fc597c..4b9ae0648 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Después de guardar, volvé a conectarte a tu router en {url}.", "bridgeRedirectTitle": "Volvé a conectarte a tu router", "bridgeRedirectMessage": "Tu router ahora es un puente transparente y ya no asigna direcciones IP locales. Volvé a conectarte en {url}.", - "bridgeRedirectButton": "Ir al router" + "bridgeRedirectButton": "Ir al router", + "lanIpRedirectTitle": "Volvé a conectarte a tu router", + "lanIpRedirectMessage": "La dirección IP de tu router cambió. Volvé a conectarte a él en {url}. Puede que tengas que esperar un momento a que tu dispositivo obtenga una nueva dirección.", + "lanIpRedirectButton": "Ir al router" } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 020574177..7e06b901e 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Yhdistä tallennuksen jälkeen reitittimeesi uudelleen osoitteessa {url}.", "bridgeRedirectTitle": "Yhdistä reitittimeesi uudelleen", "bridgeRedirectMessage": "Reitittimesi toimii nyt läpinäkyvänä siltana eikä enää jaa paikallisia IP-osoitteita. Yhdistä uudelleen osoitteessa {url}.", - "bridgeRedirectButton": "Siirry reitittimeen" + "bridgeRedirectButton": "Siirry reitittimeen", + "lanIpRedirectTitle": "Yhdistä reitittimeesi uudelleen", + "lanIpRedirectMessage": "Reitittimesi IP-osoite on muuttunut. Yhdistä siihen uudelleen osoitteessa {url}. Sinun on ehkä odotettava hetki, että laitteesi saa uuden osoitteen.", + "lanIpRedirectButton": "Siirry reitittimeen" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9f57cdb1a..a3a83c4ac 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Après l'enregistrement, reconnectez-vous à votre routeur à l'adresse {url}.", "bridgeRedirectTitle": "Se reconnecter à votre routeur", "bridgeRedirectMessage": "Votre routeur fonctionne désormais comme un pont transparent et n'attribue plus d'adresses IP locales. Reconnectez-vous à l'adresse {url}.", - "bridgeRedirectButton": "Accéder au routeur" + "bridgeRedirectButton": "Accéder au routeur", + "lanIpRedirectTitle": "Se reconnecter à votre routeur", + "lanIpRedirectMessage": "L'adresse IP de votre routeur a changé. Reconnectez-vous à celui-ci à l'adresse {url}. Vous devrez peut-être patienter un instant le temps que votre appareil obtienne une nouvelle adresse.", + "lanIpRedirectButton": "Accéder au routeur" } diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 4bb7a17ac..374f912b3 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Après l'enregistrement, reconnectez-vous à votre routeur à l'adresse {url}.", "bridgeRedirectTitle": "Se reconnecter à votre routeur", "bridgeRedirectMessage": "Votre routeur fonctionne maintenant comme un pont transparent et n'attribue plus d'adresses IP locales. Reconnectez-vous à l'adresse {url}.", - "bridgeRedirectButton": "Accéder au routeur" + "bridgeRedirectButton": "Accéder au routeur", + "lanIpRedirectTitle": "Se reconnecter à votre routeur", + "lanIpRedirectMessage": "L'adresse IP de votre routeur a changé. Reconnectez-vous à celui-ci à l'adresse {url}. Vous devrez peut-être patienter un moment le temps que votre appareil obtienne une nouvelle adresse.", + "lanIpRedirectButton": "Accéder au routeur" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 798270e98..f1573d76e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Setelah menyimpan, sambungkan kembali ke router Anda di {url}.", "bridgeRedirectTitle": "Sambungkan kembali ke router Anda", "bridgeRedirectMessage": "Router Anda sekarang menjadi bridge transparan dan tidak lagi memberikan alamat IP lokal. Sambungkan kembali di {url}.", - "bridgeRedirectButton": "Buka router" + "bridgeRedirectButton": "Buka router", + "lanIpRedirectTitle": "Sambungkan kembali ke router Anda", + "lanIpRedirectMessage": "Alamat IP router Anda telah berubah. Sambungkan kembali ke router di {url}. Anda mungkin perlu menunggu sebentar hingga perangkat Anda memperoleh alamat baru.", + "lanIpRedirectButton": "Buka router" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 72887c8f0..a94865c3d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Dopo il salvataggio, riconnettiti al router all'indirizzo {url}.", "bridgeRedirectTitle": "Riconnettiti al router", "bridgeRedirectMessage": "Il router ora funziona come bridge trasparente e non assegna più indirizzi IP locali. Riconnettiti all'indirizzo {url}.", - "bridgeRedirectButton": "Vai al router" + "bridgeRedirectButton": "Vai al router", + "lanIpRedirectTitle": "Riconnettiti al router", + "lanIpRedirectMessage": "L'indirizzo IP del router è cambiato. Riconnettiti a esso all'indirizzo {url}. Potrebbe essere necessario attendere un momento affinché il dispositivo ottenga un nuovo indirizzo.", + "lanIpRedirectButton": "Vai al router" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 14f461a15..bcf08d169 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "保存後、{url} からルーターに再接続してください。", "bridgeRedirectTitle": "ルーターに再接続", "bridgeRedirectMessage": "ルーターは現在、透過的なブリッジとして動作しており、ローカル IP アドレスを割り当てません。{url} から再接続してください。", - "bridgeRedirectButton": "ルーターに移動" + "bridgeRedirectButton": "ルーターに移動", + "lanIpRedirectTitle": "ルーターに再接続", + "lanIpRedirectMessage": "ルーターの IP アドレスが変更されました。{url} から再接続してください。デバイスが新しいアドレスを取得するまで少し時間がかかる場合があります。", + "lanIpRedirectButton": "ルーターに移動" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index ccc67da63..474c94320 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "저장한 후 {url} 에서 라우터에 다시 연결하세요.", "bridgeRedirectTitle": "라우터에 다시 연결", "bridgeRedirectMessage": "라우터가 이제 투명 브리지로 작동하며 로컬 IP 주소를 더 이상 할당하지 않습니다. {url} 에서 다시 연결하세요.", - "bridgeRedirectButton": "라우터로 이동" + "bridgeRedirectButton": "라우터로 이동", + "lanIpRedirectTitle": "라우터에 다시 연결", + "lanIpRedirectMessage": "라우터의 IP 주소가 변경되었습니다. {url}에서 다시 연결하세요. 기기가 새 주소를 받을 때까지 잠시 기다려야 할 수 있습니다.", + "lanIpRedirectButton": "라우터로 이동" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 62299c027..f43d51f0a 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Etter lagring kobler du til ruteren din på nytt på {url}.", "bridgeRedirectTitle": "Koble til ruteren på nytt", "bridgeRedirectMessage": "Ruteren din fungerer nå som en transparent bro og tildeler ikke lenger lokale IP-adresser. Koble til på nytt på {url}.", - "bridgeRedirectButton": "Gå til ruteren" + "bridgeRedirectButton": "Gå til ruteren", + "lanIpRedirectTitle": "Koble til ruteren på nytt", + "lanIpRedirectMessage": "Ruterens IP-adresse er endret. Koble til den på nytt på {url}. Du må kanskje vente et øyeblikk til enheten får en ny adresse.", + "lanIpRedirectButton": "Gå til ruteren" } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 9d45c4fe8..be98066c8 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Maak na het opslaan opnieuw verbinding met uw router via {url}.", "bridgeRedirectTitle": "Opnieuw verbinden met uw router", "bridgeRedirectMessage": "Uw router is nu een transparante bridge en wijst geen lokale IP-adressen meer toe. Maak opnieuw verbinding via {url}.", - "bridgeRedirectButton": "Naar router" + "bridgeRedirectButton": "Naar router", + "lanIpRedirectTitle": "Opnieuw verbinden met uw router", + "lanIpRedirectMessage": "Het IP-adres van uw router is gewijzigd. Maak opnieuw verbinding via {url}. Mogelijk moet u even wachten totdat uw apparaat een nieuw adres heeft ontvangen.", + "lanIpRedirectButton": "Naar router" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 114f26414..eee545e89 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Po zapisaniu połącz się ponownie z routerem pod adresem {url}.", "bridgeRedirectTitle": "Połącz się ponownie z routerem", "bridgeRedirectMessage": "Twój router działa teraz jako most przezroczysty i nie przydziela już lokalnych adresów IP. Połącz się ponownie pod adresem {url}.", - "bridgeRedirectButton": "Przejdź do routera" + "bridgeRedirectButton": "Przejdź do routera", + "lanIpRedirectTitle": "Połącz się ponownie z routerem", + "lanIpRedirectMessage": "Adres IP routera uległ zmianie. Połącz się z nim ponownie pod adresem {url}. Może być konieczne odczekanie chwili, aż urządzenie uzyska nowy adres.", + "lanIpRedirectButton": "Przejdź do routera" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 7a909ca23..66d3ad050 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Após salvar, reconecte-se ao seu roteador em {url}.", "bridgeRedirectTitle": "Reconectar ao seu roteador", "bridgeRedirectMessage": "Seu roteador agora é uma ponte transparente e não distribui mais endereços IP locais. Reconecte-se em {url}.", - "bridgeRedirectButton": "Ir para o roteador" + "bridgeRedirectButton": "Ir para o roteador", + "lanIpRedirectTitle": "Reconectar ao seu roteador", + "lanIpRedirectMessage": "O endereço IP do seu roteador foi alterado. Reconecte-se a ele em {url}. Talvez seja necessário aguardar um momento até que seu dispositivo obtenha um novo endereço.", + "lanIpRedirectButton": "Ir para o roteador" } diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index e3e78ae38..1b3ee0c8a 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Após guardar, volte a ligar-se ao seu router em {url}.", "bridgeRedirectTitle": "Voltar a ligar ao seu router", "bridgeRedirectMessage": "O seu router funciona agora como uma ponte transparente e já não atribui endereços IP locais. Volte a ligar-se em {url}.", - "bridgeRedirectButton": "Ir para o router" + "bridgeRedirectButton": "Ir para o router", + "lanIpRedirectTitle": "Voltar a ligar ao seu router", + "lanIpRedirectMessage": "O endereço IP do seu router foi alterado. Volte a ligar-se a ele em {url}. Poderá ter de aguardar um momento até que o seu dispositivo obtenha um novo endereço.", + "lanIpRedirectButton": "Ir para o router" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c67d8a31f..901e0d595 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "После сохранения снова подключитесь к маршрутизатору по адресу {url}.", "bridgeRedirectTitle": "Повторное подключение к маршрутизатору", "bridgeRedirectMessage": "Теперь ваш маршрутизатор работает как прозрачный мост и больше не назначает локальные IP-адреса. Снова подключитесь по адресу {url}.", - "bridgeRedirectButton": "Перейти к маршрутизатору" + "bridgeRedirectButton": "Перейти к маршрутизатору", + "lanIpRedirectTitle": "Повторное подключение к маршрутизатору", + "lanIpRedirectMessage": "IP-адрес вашего маршрутизатора изменился. Повторно подключитесь к нему по адресу {url}. Возможно, придётся подождать, пока ваше устройство получит новый адрес.", + "lanIpRedirectButton": "Перейти к маршрутизатору" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index e0ae96865..bb3d283b6 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Efter att du har sparat ansluter du till din router igen på {url}.", "bridgeRedirectTitle": "Anslut till din router igen", "bridgeRedirectMessage": "Din router fungerar nu som en transparent brygga och delar inte längre ut lokala IP-adresser. Anslut igen på {url}.", - "bridgeRedirectButton": "Gå till routern" + "bridgeRedirectButton": "Gå till routern", + "lanIpRedirectTitle": "Anslut till din router igen", + "lanIpRedirectMessage": "Routerns IP-adress har ändrats. Anslut till den igen på {url}. Du kan behöva vänta en stund tills din enhet får en ny adress.", + "lanIpRedirectButton": "Gå till routern" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 9e73a5512..86efb2761 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "หลังจากบันทึกแล้ว โปรดเชื่อมต่อกับเราเตอร์ของคุณอีกครั้งที่ {url}", "bridgeRedirectTitle": "เชื่อมต่อกับเราเตอร์ของคุณอีกครั้ง", "bridgeRedirectMessage": "ขณะนี้เราเตอร์ของคุณทำงานเป็นบริดจ์แบบโปร่งใสและไม่แจกจ่ายที่อยู่ IP ภายในอีกต่อไป โปรดเชื่อมต่อใหม่ที่ {url}", - "bridgeRedirectButton": "ไปที่เราเตอร์" + "bridgeRedirectButton": "ไปที่เราเตอร์", + "lanIpRedirectTitle": "เชื่อมต่อกับเราเตอร์ของคุณอีกครั้ง", + "lanIpRedirectMessage": "ที่อยู่ IP ของเราเตอร์ของคุณเปลี่ยนแปลงแล้ว เชื่อมต่อกับเราเตอร์อีกครั้งที่ {url} คุณอาจต้องรอสักครู่ให้อุปกรณ์ของคุณได้รับที่อยู่ใหม่", + "lanIpRedirectButton": "ไปที่เราเตอร์" } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index e987d2def..f47c78720 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Kaydettikten sonra {url} adresinden yönlendiricinize yeniden bağlanın.", "bridgeRedirectTitle": "Yönlendiricinize yeniden bağlanın", "bridgeRedirectMessage": "Yönlendiriciniz artık şeffaf bir köprü olarak çalışıyor ve yerel IP adresleri dağıtmıyor. {url} adresinden yeniden bağlanın.", - "bridgeRedirectButton": "Yönlendiriciye git" + "bridgeRedirectButton": "Yönlendiriciye git", + "lanIpRedirectTitle": "Yönlendiricinize yeniden bağlanın", + "lanIpRedirectMessage": "Yönlendiricinizin IP adresi değişti. {url} adresinden ona yeniden bağlanın. Cihazınızın yeni bir adres alması için bir süre beklemeniz gerekebilir.", + "lanIpRedirectButton": "Yönlendiriciye git" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 72548dfcf..221d3b430 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "Sau khi lưu, hãy kết nối lại với bộ định tuyến của bạn tại {url}.", "bridgeRedirectTitle": "Kết nối lại với bộ định tuyến của bạn", "bridgeRedirectMessage": "Bộ định tuyến của bạn hiện là cầu nối trong suốt và không còn cấp địa chỉ IP cục bộ nữa. Hãy kết nối lại tại {url}.", - "bridgeRedirectButton": "Đi tới bộ định tuyến" + "bridgeRedirectButton": "Đi tới bộ định tuyến", + "lanIpRedirectTitle": "Kết nối lại với bộ định tuyến của bạn", + "lanIpRedirectMessage": "Địa chỉ IP của bộ định tuyến đã thay đổi. Kết nối lại với bộ định tuyến tại {url}. Bạn có thể cần đợi một lát để thiết bị của mình nhận được địa chỉ mới.", + "lanIpRedirectButton": "Đi tới bộ định tuyến" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6e056f679..e1e9c96ef 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "保存后,请改用 {url} 重新连接到您的路由器。", "bridgeRedirectTitle": "重新连接到您的路由器", "bridgeRedirectMessage": "您的路由器现在是透明网桥,不再分配本地 IP 地址。请改用 {url} 重新连接。", - "bridgeRedirectButton": "前往路由器" + "bridgeRedirectButton": "前往路由器", + "lanIpRedirectTitle": "重新连接到您的路由器", + "lanIpRedirectMessage": "您的路由器 IP 地址已更改。请通过 {url} 重新连接。您可能需要稍等片刻,等待设备获取新地址。", + "lanIpRedirectButton": "前往路由器" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 6d7fde67f..e4c28e102 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1125,5 +1125,8 @@ "bridgeReconnectHint": "儲存後,請改用 {url} 重新連線到您的路由器。", "bridgeRedirectTitle": "重新連線到您的路由器", "bridgeRedirectMessage": "您的路由器現在是透明橋接器,不再配發本機 IP 位址。請改用 {url} 重新連線。", - "bridgeRedirectButton": "前往路由器" + "bridgeRedirectButton": "前往路由器", + "lanIpRedirectTitle": "重新連線到您的路由器", + "lanIpRedirectMessage": "您的路由器 IP 位址已變更。請透過 {url} 重新連線。您可能需要稍候片刻,等待裝置取得新位址。", + "lanIpRedirectButton": "前往路由器" } diff --git a/lib/page/local_network/models/local_network_feature_state.dart b/lib/page/local_network/models/local_network_feature_state.dart index 8e2e272ee..ec69d0024 100644 --- a/lib/page/local_network/models/local_network_feature_state.dart +++ b/lib/page/local_network/models/local_network_feature_state.dart @@ -27,6 +27,14 @@ class LocalNetworkFeatureState settings.original.model.ipAddress != settings.current.model.ipAddress || settings.original.model.subnetMask != settings.current.model.subnetMask; + /// True when the router IP address specifically changed. This is the trigger + /// for the post-save redirect + SSE disconnect: only an IP change makes the + /// old origin unreachable and drops the connection. A subnet-mask-only change + /// keeps the same IP, so the current connection survives and normal save + /// (await response + re-fetch) applies. + bool get hasIpAddressChange => + settings.original.model.ipAddress != settings.current.model.ipAddress; + @override LocalNetworkFeatureState copyWith({ Preservable? settings, diff --git a/lib/page/local_network/providers/usp_local_network_notifier.dart b/lib/page/local_network/providers/usp_local_network_notifier.dart index f07590b98..cd234a298 100644 --- a/lib/page/local_network/providers/usp_local_network_notifier.dart +++ b/lib/page/local_network/providers/usp_local_network_notifier.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/framework/preservable_contract.dart'; import 'package:privacy_gui/framework/preservable_notifier_mixin.dart'; @@ -96,6 +97,47 @@ class UspLocalNetworkNotifier } } + // --------------------------------------------------------------------------- + // save — override the mixin's default (performSave -> markAsSaved -> refetch) + // to special-case a router IP / subnet change, which makes the router + // unreachable on this origin. + // --------------------------------------------------------------------------- + + @override + Future save() async { + // Detect the IP change BEFORE saving: markAsSaved() below collapses + // original into current, so this must be read up front. Only an IP address + // change (not a mask-only change) drops the current connection, so that is + // the trigger for the redirect + SSE disconnect. + final ipChanged = state.hasIpAddressChange; + + await performSave(); + markAsSaved(); + + if (ipChanged) { + // Changing the router LAN IP makes it unreachable on this origin + // (the old address stops answering). Two effects are handled here, both + // specific to this transition: + // + // 1. Drop SSE intentionally. disconnect() sets _intentionalDisconnect, + // which stops the reconnect backoff and suppresses onReconnectFailed, + // so the app-level recovery flow (2 reconnect failures -> + // waitingForRecovery) never fires on top of the LAN IP redirect + // dialog. + // 2. Skip the post-save re-fetch. The SET already succeeded; the device + // is now gone from this origin, so fetch(forceRemote: true) would only + // time out and surface a spurious error for an operation that actually + // succeeded. The redirect dialog is the only valid next step. + await ref.read(sseManagerProvider)?.disconnect(); + return state; + } + + // No IP change (e.g. only DHCP or subnet-mask fields changed): the + // connection is still alive, so re-fetch so the dashboard card updates too. + ref.invalidate(lanDataProvider); + return fetch(forceRemote: true); + } + // --------------------------------------------------------------------------- // performSave — required by PreservableAutoDisposeNotifierMixin // --------------------------------------------------------------------------- @@ -114,9 +156,6 @@ class UspLocalNetworkNotifier await _svc.save(original: o, pending: p); logger.d('[USP][Network][LAN]: Saved'); }); - - // Force data provider to re-fetch so dashboard card updates too. - ref.invalidate(lanDataProvider); } on ServiceError catch (e) { logger.e('[USP][Network][LAN]: Save failed', error: e); rethrow; @@ -148,8 +187,8 @@ class UspLocalNetworkNotifier /// Use this for onChange handlers to avoid TextField unfocus on Web. /// Call [validate] separately on unfocus. /// - /// When router IP changes, locked-prefix octets of pool IPs are - /// automatically synced so the user doesn't have to retype them. + /// When router IP OR subnet mask changes, locked-prefix octets of pool IPs + /// are automatically synced so the user doesn't have to retype them. void updateSetting( LocalNetworkUIModel Function(LocalNetworkUIModel) updater) { final current = state.settings.current; @@ -160,9 +199,15 @@ class UspLocalNetworkNotifier newModel = _svc.applyDhcpDefaults(newModel); } - // Auto-sync pool prefix when router IP changes - if (newModel.ipAddress != current.model.ipAddress && - newModel.subnetMask.isNotEmpty) { + // Auto-sync pool prefix when the router IP OR the subnet mask changes. + // The mask must be included: it determines lockedOctetCount, which the UI + // uses to lock pool prefix octets read-only. If only the IP triggered the + // sync, changing the mask (e.g. /16 → /24) would widen the locked range + // without updating the pool, leaving pool octets that are both out-of-subnet + // (validation error) AND read-only (uneditable) — a dead end. + final ipChanged = newModel.ipAddress != current.model.ipAddress; + final maskChanged = newModel.subnetMask != current.model.subnetMask; + if ((ipChanged || maskChanged) && newModel.subnetMask.isNotEmpty) { final locked = _svc.lockedOctetCount(newModel.subnetMask); if (locked > 0) { newModel = newModel.copyWith( diff --git a/lib/page/local_network/services/usp_local_network_service.dart b/lib/page/local_network/services/usp_local_network_service.dart index 88946d0cc..0fc0c884c 100644 --- a/lib/page/local_network/services/usp_local_network_service.dart +++ b/lib/page/local_network/services/usp_local_network_service.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; +import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/generated/lan_network_info.g.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -24,16 +27,30 @@ class UspLocalNetworkService { // ─── CRUD ────────────────────────────────────────────────── + /// Time budget for a SET that changes the router IP. Changing the LAN IP + /// makes the firmware drop the connection carrying the SET response, so the + /// response can never arrive — bound the wait instead of hanging. + static const _ipChangeSetTimeout = Duration(seconds: 4); + /// Save changed LAN settings. Only sends fields that differ from original. + /// + /// When the router IP address changes, the SET is terminal-by-design: the + /// firmware applies the new IP and drops the connection carrying the SET + /// response, so a timeout / transport error on an IP-changing SET is the + /// expected signature of success (the SET was received and applied before the + /// disconnect). Any fault the router actively returns BEFORE the disconnect — + /// a validation/resource/partial/complete failure — still propagates, so a + /// genuine config failure is never hidden. When the IP does not change, the + /// response is awaited normally and every error surfaces. Future save({ required LocalNetworkUIModel original, required LocalNetworkUIModel pending, }) async { + final ipAddressChanged = original.ipAddress != pending.ipAddress; try { - final result = await LanNetworkInfo.update( + final updateFuture = LanNetworkInfo.update( _usp, - ipAddress: - original.ipAddress != pending.ipAddress ? pending.ipAddress : null, + ipAddress: ipAddressChanged ? pending.ipAddress : null, subnetMask: original.subnetMask != pending.subnetMask ? pending.subnetMask : null, @@ -55,30 +72,63 @@ class UspLocalNetworkService { ? joinDnsServers( pending.dnsServer1, pending.dnsServer2, pending.dnsServer3) : null, + allowPartial: true, ); - final parsed = UspResultParser.parseSetResult(result); - switch (parsed) { - case UspSuccess(): - break; - case UspPartialSuccess( - :final errorSummary, - :final successes, - :final failures - ): - throw UspPartialFailureError( - summary: 'Local network update partial failure: $errorSummary', - successPaths: successes.map((s) => s.requestedPath).toList(), - failures: failures, - ); - case UspFailure(:final errorSummary, :final errors): - throw UspCompleteFailureError( - summary: 'Local network update failed: $errorSummary', - failures: errors, - ); - } + + // On an IP change the response rides a connection the firmware is about + // to drop; bound the wait so we don't hang on a reply that can't arrive. + final result = ipAddressChanged + ? await updateFuture.timeout(_ipChangeSetTimeout) + : await updateFuture; + + _handleSetResult(result); + } on TimeoutException { + // No response within the budget: the firmware applied the new IP and + // dropped the connection, so the SET_RESP can never arrive. This is only + // reachable when the IP changed (the timeout is applied only then). + logger.i('[USP][Network][LAN]: IP-change SET timed out after ' + '${_ipChangeSetTimeout.inSeconds}s — treating as success ' + '(firmware dropped the connection applying the new IP)'); } catch (e) { + // A fault the router actively returned (ServiceError from + // _handleSetResult) means the connection was alive and the config was + // rejected → propagate, even on an IP change. if (e is ServiceError) rethrow; - throw mapUspErrorToServiceError(e); + // Transport / connectivity errors are raw (non-ServiceError). On an IP + // change they are the disconnect signature = success; otherwise they are + // a genuine failure. + final mapped = mapUspErrorToServiceError(e); + if (ipAddressChanged && + (mapped is NetworkError || mapped is ConnectivityError)) { + logger.i('[USP][Network][LAN]: IP-change SET hit a transport error ' + '— treating as success (firmware dropped the connection)'); + return; + } + throw mapped; + } + } + + /// Parse a SET result and throw the appropriate [ServiceError] on failure. + void _handleSetResult(Map result) { + final parsed = UspResultParser.parseSetResult(result); + switch (parsed) { + case UspSuccess(): + break; + case UspPartialSuccess( + :final errorSummary, + :final successes, + :final failures + ): + throw UspPartialFailureError( + summary: 'Local network update partial failure: $errorSummary', + successPaths: successes.map((s) => s.requestedPath).toList(), + failures: failures, + ); + case UspFailure(:final errorSummary, :final errors): + throw UspCompleteFailureError( + summary: 'Local network update failed: $errorSummary', + failures: errors, + ); } } diff --git a/lib/page/local_network/views/helpers/lan_ip_redirect_dialog.dart b/lib/page/local_network/views/helpers/lan_ip_redirect_dialog.dart new file mode 100644 index 000000000..e2169ffb4 --- /dev/null +++ b/lib/page/local_network/views/helpers/lan_ip_redirect_dialog.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:privacy_gui/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/core/utils/assign_ip/assign_ip.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Shows the post-save LAN IP dialog: the router LAN IP just changed, so the +/// old address is no longer reachable on this origin. The primary button +/// navigates the browser to `https://.local`, letting the browser +/// handle DNS (mDNS) / cert / retry — the only reliable path to reach the +/// router at its new address once the client picks up a new DHCP lease. +/// +/// [navigate] defaults to the real web redirect and is injectable for tests. +Future showLanIpRedirectDialog( + BuildContext context, { + required String hostName, + void Function(String url) navigate = assignWebLocation, +}) { + final url = 'https://$hostName.local'; + final l = loc(context); + return showSimpleAppDialog( + context, + dismissible: false, + title: l.lanIpRedirectTitle, + content: AppText( + l.lanIpRedirectMessage(url), + variant: AppTextVariant.bodyMedium, + fontWeight: FontWeight.bold, + ), + actions: [ + // No dismiss/close action: once the LAN IP changed, the router is + // unreachable on this origin, so redirecting to https://.local + // is the only valid next step. The dialog is non-dismissible (above). + AppButton.primary( + label: l.lanIpRedirectButton, + onTap: () => navigate(url), + ), + ], + ); +} diff --git a/lib/page/local_network/views/usp_local_network_view.dart b/lib/page/local_network/views/usp_local_network_view.dart index 9d2c786b4..dc4ba4c1c 100644 --- a/lib/page/local_network/views/usp_local_network_view.dart +++ b/lib/page/local_network/views/usp_local_network_view.dart @@ -11,6 +11,7 @@ import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/local_network/models/local_network_feature_state.dart'; import 'package:privacy_gui/page/local_network/providers/usp_local_network_notifier.dart'; +import 'package:privacy_gui/page/local_network/views/helpers/lan_ip_redirect_dialog.dart'; import 'package:privacy_gui/page/shell/usp_top_bar.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -407,8 +408,18 @@ class _UspLocalNetworkViewState extends ConsumerState { WidgetRef ref, LocalNetworkFeatureState state, ) async { + // Read the change intent and hostname BEFORE saving: save() collapses + // original into current (markAsSaved) and may drop SSE, so these must be + // captured up front. The disconnection warning covers any IP/subnet change, + // but only an IP address change makes the old origin unreachable and + // triggers the redirect. hostName is the redirect target + // (https://.local). + final networkChanged = state.hasNetworkChange; + final ipChanged = state.hasIpAddressChange; + final hostName = state.settings.current.model.hostName; + // Warn if router IP or subnet changed (may cause disconnection) - if (state.hasNetworkChange) { + if (networkChanged) { final confirmed = await _showNetworkChangeConfirmation(context); if (confirmed != true || !context.mounted) return; } @@ -418,7 +429,16 @@ class _UspLocalNetworkViewState extends ConsumerState { context, ref.read(uspLocalNetworkProvider.notifier).save(), ); - if (context.mounted) { + if (!context.mounted) return; + + // Only redirect after a confirmed save when the IP address actually + // changed: the old address is now unreachable, so the browser must be + // sent to the router's new .local address. On native, showLanIpRedirect + // Dialog's navigate is a no-op. Otherwise (mask-only or DHCP fields), + // the connection survives — stay on the page with a success message. + if (ipChanged && hostName.isNotEmpty) { + await showLanIpRedirectDialog(context, hostName: hostName); + } else { showSuccessSnackBar(context, loc(context).localNetworkSettingsSaved); } } catch (e) { diff --git a/test/page/local_network/providers/usp_local_network_notifier_test.dart b/test/page/local_network/providers/usp_local_network_notifier_test.dart index cbfa33785..384e38c2a 100644 --- a/test/page/local_network/providers/usp_local_network_notifier_test.dart +++ b/test/page/local_network/providers/usp_local_network_notifier_test.dart @@ -2,24 +2,34 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; +import 'package:privacy_gui/core/usp/services/sse_manager.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; import 'package:privacy_gui/page/local_network/models/local_network_ui_model.dart'; import 'package:privacy_gui/page/local_network/providers/lan_data_provider.dart'; +import 'package:privacy_gui/core/usp/services/usp_client.dart'; import 'package:privacy_gui/page/local_network/providers/usp_local_network_notifier.dart'; import 'package:privacy_gui/page/local_network/services/usp_local_network_service.dart'; class MockUspLocalNetworkService extends Mock implements UspLocalNetworkService {} -/// Test-only notifier that returns canned data. +class MockSseManager extends Mock implements SseManager {} + +class MockUspClient extends Mock implements UspClient {} + +/// Test-only notifier that returns canned data and counts build() runs so +/// tests can assert whether save() triggered a post-save re-fetch. class _TestLanDataNotifier extends LanDataNotifier { final LanData _testData; final ServiceError? errorToThrow; + int buildCount = 0; _TestLanDataNotifier(this._testData, {this.errorToThrow}); @override Future build() async { + buildCount++; if (errorToThrow != null) throw errorToThrow!; return _testData; } @@ -27,6 +37,7 @@ class _TestLanDataNotifier extends LanDataNotifier { void main() { late MockUspLocalNetworkService mockService; + late MockSseManager mockSseManager; final testLanData = LanData( model: LanInfoUIModel( @@ -47,10 +58,12 @@ void main() { setUp(() { mockService = MockUspLocalNetworkService(); + mockSseManager = MockSseManager(); when(() => mockService.validateAll(any())).thenReturn({}); when(() => mockService.lockedOctetCount(any())).thenReturn(3); when(() => mockService.syncPrefix(any(), any(), any())) .thenAnswer((inv) => inv.positionalArguments[0] as String); + when(() => mockSseManager.disconnect()).thenAnswer((_) async {}); }); ProviderContainer createContainer({LanData? data}) { @@ -58,6 +71,7 @@ void main() { overrides: [ uspLocalNetworkServiceProvider.overrideWithValue(mockService), uspMutationLockProvider.overrideWithValue(UspMutationLock()), + sseManagerProvider.overrideWithValue(mockSseManager), lanDataProvider .overrideWith(() => _TestLanDataNotifier(data ?? testLanData)), ], @@ -233,5 +247,125 @@ void main() { expect(container.read(uspLocalNetworkProvider).status.isSaving, isFalse); container.dispose(); }); + + test('save on IP change disconnects SSE and skips the post-save re-fetch', + () async { + when(() => mockService.save( + original: any(named: 'original'), + pending: any(named: 'pending'), + )).thenAnswer((_) async {}); + final container = createContainer(); + await Future.delayed(Duration.zero); + + final lanNotifier = + container.read(lanDataProvider.notifier) as _TestLanDataNotifier; + // build() ran once during initial fetch. + expect(lanNotifier.buildCount, 1); + + final notifier = container.read(uspLocalNetworkProvider.notifier); + notifier.updateSetting((m) => m.copyWith(ipAddress: '192.168.5.1')); + await notifier.save(); + + // SSE is dropped so the recovery flow never fires on top of the redirect + // dialog. + verify(() => mockSseManager.disconnect()).called(1); + // No re-fetch: the router is unreachable at the old IP, so lanDataProvider + // must NOT be invalidated/rebuilt (a re-fetch would time out). + expect(lanNotifier.buildCount, 1); + container.dispose(); + }); + + test('save on non-network change re-fetches and does not disconnect SSE', + () async { + when(() => mockService.save( + original: any(named: 'original'), + pending: any(named: 'pending'), + )).thenAnswer((_) async {}); + final container = createContainer(); + await Future.delayed(Duration.zero); + + final lanNotifier = + container.read(lanDataProvider.notifier) as _TestLanDataNotifier; + expect(lanNotifier.buildCount, 1); + + final notifier = container.read(uspLocalNetworkProvider.notifier); + // Only a DHCP field changes — IP/subnet stay the same. + notifier.updateSetting((m) => m.copyWith(dnsServer1: '9.9.9.9')); + await notifier.save(); + await Future.delayed(Duration.zero); + + verifyNever(() => mockSseManager.disconnect()); + // lanDataProvider is invalidated → build() runs a second time. + expect(lanNotifier.buildCount, 2); + container.dispose(); + }); + }); + + // Regression: pool prefix auto-sync must follow BOTH the router IP and the + // subnet mask. Uses the REAL service so the pure lockedOctetCount/syncPrefix + // logic runs, reproducing the dead-end from issue #1039 follow-up. + group('UspLocalNetworkNotifier — pool prefix auto-sync (real service)', () { + // Initial: ip=192.168.1.1, mask=255.255.0.0 (/16), pool=192.168.1.x + final startLanData = LanData( + model: LanInfoUIModel( + hostName: 'MyRouter', + ipAddress: '192.168.1.1', + subnetMask: '255.255.0.0', + dhcpEnabled: true, + minAddress: '192.168.1.100', + maxAddress: '192.168.1.200', + leaseTimeMinutes: 1440, + dnsServers: '1.1.1.1', + ), + ); + + ProviderContainer createRealContainer() { + final container = ProviderContainer( + overrides: [ + uspLocalNetworkServiceProvider + .overrideWithValue(UspLocalNetworkService(MockUspClient())), + uspMutationLockProvider.overrideWithValue(UspMutationLock()), + sseManagerProvider.overrideWithValue(mockSseManager), + lanDataProvider + .overrideWith(() => _TestLanDataNotifier(startLanData)), + ], + ); + container.listen(uspLocalNetworkProvider, (_, __) {}); + return container; + } + + test( + 'tightening the mask (/16 → /24) re-syncs pool prefix to the router IP ' + 'so locked octets never go out-of-subnet', () async { + final container = createRealContainer(); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspLocalNetworkProvider.notifier); + + // 1. Change IP to 192.168.2.1 (mask still /16 → locks 2 octets, so the + // 3rd octet of the pool stays .1 and is still in-subnet). + notifier.updateSetting((m) => m.copyWith(ipAddress: '192.168.2.1')); + var model = + container.read(uspLocalNetworkProvider).settings.current.model; + expect(model.minAddress, '192.168.1.100'); + expect(model.maxAddress, '192.168.1.200'); + + // 2. Tighten the mask to /24. lockedOctetCount becomes 3, so the UI would + // lock the pool's 3rd octet read-only. The pool MUST be re-synced to + // the router's 3rd octet (2), otherwise it stays 192.168.1.x — + // out-of-subnet AND uneditable (the reported dead-end). + notifier.updateSetting((m) => m.copyWith(subnetMask: '255.255.255.0')); + model = container.read(uspLocalNetworkProvider).settings.current.model; + expect(model.minAddress, '192.168.2.100'); + expect(model.maxAddress, '192.168.2.200'); + + // Locked prefix and pool now agree → validation passes for the pool. + notifier.validate(); + final errors = + container.read(uspLocalNetworkProvider).status.validationErrors; + expect(errors['minAddress'], isNull); + expect(errors['maxAddress'], isNull); + container.dispose(); + }); }); } diff --git a/test/page/local_network/services/usp_local_network_service_test.dart b/test/page/local_network/services/usp_local_network_service_test.dart index d314fd283..eb462a332 100644 --- a/test/page/local_network/services/usp_local_network_service_test.dart +++ b/test/page/local_network/services/usp_local_network_service_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; @@ -433,33 +436,38 @@ void main() { test('save succeeds when firmware returns success', () async { when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); - when(() => mockUsp.set(any())).thenAnswer((_) async => { - 'success': true, - 'result': {'data': {}}, - }); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': true, + 'result': {'data': {}}, + }); await service.save( original: _model(), pending: _model(hostName: 'NewRouter'), ); - verify(() => mockUsp.set(any())).called(1); + // LAN settings span multiple USP Services (Device.IP + Device.DHCPv4 + + // Device.DeviceInfo). The save MUST pass allowPartial: true, otherwise + // firmware rejects the cross-service atomic SET with error 7005 (#1039). + verify(() => mockUsp.set(any(), allowPartial: true)).called(1); }); test('save throws UspCompleteFailureError on firmware failure', () async { when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); - when(() => mockUsp.set(any())).thenAnswer((_) async => { - 'success': false, - 'result': { - 'data': {}, - 'error': { - 'Device.DHCPv4.Server.Pool.1.MinAddress': { - 'errorCode': 7004, - 'errorMessage': 'Parameter not writable' - } - } - }, - }); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': false, + 'result': { + 'data': {}, + 'error': { + 'Device.DHCPv4.Server.Pool.1.MinAddress': { + 'errorCode': 7004, + 'errorMessage': 'Parameter not writable' + } + } + }, + }); expect( () => service.save( @@ -472,20 +480,21 @@ void main() { test('save throws UspPartialFailureError on partial success', () async { when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); - when(() => mockUsp.set(any())).thenAnswer((_) async => { - 'success': true, - 'result': { - 'data': { - 'Device.DHCPv4.Server.Pool.1.MinAddress': '192.168.1.50' - }, - 'error': { - 'Device.DHCPv4.Server.Pool.1.MaxAddress': { - 'errorCode': 7004, - 'errorMessage': 'Parameter not writable' - } - } - }, - }); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': true, + 'result': { + 'data': { + 'Device.DHCPv4.Server.Pool.1.MinAddress': '192.168.1.50' + }, + 'error': { + 'Device.DHCPv4.Server.Pool.1.MaxAddress': { + 'errorCode': 7004, + 'errorMessage': 'Parameter not writable' + } + } + }, + }); expect( () => service.save( @@ -499,7 +508,7 @@ void main() { test('save maps transport error to ServiceError', () async { when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); - when(() => mockUsp.set(any())) + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) .thenThrow('Set failed: Transport error: Connection refused'); expect( @@ -510,5 +519,78 @@ void main() { throwsA(isA()), ); }); + + // --- Terminal SET on IP address change ------------------------------- + // Changing the router IP makes the firmware drop the connection carrying + // the SET response. A timeout / transport error is then the expected + // signature of success, but a fault the router actively returns still fails. + + test('save on IP change treats a Dart timeout as success', () { + // The SET response can never arrive (connection gone). Drive the 4s + // budget with fake_async so the test does not actually wait. + fakeAsync((async) { + when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) => Completer>().future); + + Object? error; + var done = false; + service + .save( + original: _model(ipAddress: '192.168.1.1'), + pending: _model(ipAddress: '192.168.2.1'), + ) + .then((_) => done = true, onError: (Object e) => error = e); + + async.elapse(const Duration(seconds: 5)); + + expect(error, isNull, + reason: 'an IP-change SET timeout must be treated as success'); + expect(done, isTrue); + }); + }); + + test('save on IP change treats a transport error as success', () async { + when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenThrow('Set failed: Transport error: Connection refused'); + + // A transport error on an IP-changing SET is the disconnect signature → + // success. (Contrast with the hostName-only case above, which surfaces.) + await expectLater( + service.save( + original: _model(ipAddress: '192.168.1.1'), + pending: _model(ipAddress: '192.168.2.1'), + ), + completes, + ); + }); + + test('save on IP change still rethrows a real firmware fault', () async { + when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); + // Router actively rejected the SET BEFORE any disconnect — a genuine + // config failure that must reach the user, never swallowed. + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': false, + 'result': { + 'data': {}, + 'error': { + 'Device.IP.Interface.1.IPv4Address.1.IPAddress': { + 'errorCode': 7006, + 'errorMessage': 'Invalid value', + }, + }, + }, + }); + + await expectLater( + service.save( + original: _model(ipAddress: '192.168.1.1'), + pending: _model(ipAddress: '192.168.2.1'), + ), + throwsA(isA()), + ); + }); }); } diff --git a/test/page/local_network/views/helpers/lan_ip_redirect_dialog_test.dart b/test/page/local_network/views/helpers/lan_ip_redirect_dialog_test.dart new file mode 100644 index 000000000..afc83a808 --- /dev/null +++ b/test/page/local_network/views/helpers/lan_ip_redirect_dialog_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/l10n/gen/app_localizations.dart'; +import 'package:privacy_gui/page/local_network/views/helpers/lan_ip_redirect_dialog.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +final _testTheme = AppTheme.create( + brightness: Brightness.light, + seedColor: Colors.blue, + designThemeBuilder: (c) => CustomDesignTheme.fromJson({ + 'style': 'flat', + }), +); + +Widget _harness({required void Function(String) navigate}) { + return MaterialApp( + theme: _testTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showLanIpRedirectDialog( + context, + hostName: 'MyRouter', + navigate: navigate, + ), + child: const Text('open'), + ), + ), + ), + ); +} + +void main() { + testWidgets('shows the .local management address', (tester) async { + await tester.pumpWidget(_harness(navigate: (_) {})); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('https://MyRouter.local'), + findsWidgets, + ); + }); + + testWidgets('go button navigates to the .local URL', (tester) async { + String? navigated; + await tester.pumpWidget(_harness(navigate: (url) => navigated = url)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Go to router')); + await tester.pumpAndSettle(); + + expect(navigated, 'https://MyRouter.local'); + }); + + testWidgets('offers no dismiss/close action — redirect is the only path', + (tester) async { + await tester.pumpWidget(_harness(navigate: (_) {})); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Once the LAN IP changed the router is unreachable on this origin, so the + // dialog must not offer a way to stay on the (now-dead) page. + expect(find.text('Go to router'), findsOneWidget); + expect(find.text('Close'), findsNothing); + }); +}