From 4159826f266d7c6b69738d0668c43b33b68b51c8 Mon Sep 17 00:00:00 2001 From: mcclure Date: Wed, 11 May 2022 11:16:51 -0400 Subject: [PATCH 01/26] Allow build on systems without git (#2514) Set git revision to "unknown" if git not available. --- app/build.gradle | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c152805b4..2a34c80ae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,9 +7,13 @@ apply from: "../instance-build.gradle" def getGitSha = { def stdout = new ByteArrayOutputStream() - exec { - commandLine 'git', 'rev-parse', '--short', 'HEAD' - standardOutput = stdout + try { + exec { + commandLine 'git', 'rev-parse', '--short', 'HEAD' + standardOutput = stdout + } + } catch (Exception e) { + return "unknown" } return stdout.toString().trim() } From b8e3b6b884d0fed3005f21fc52da3816208e2894 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Thu, 12 May 2022 18:21:33 +0200 Subject: [PATCH 02/26] fix currently logged in profiles not being visible in main drawer when offline (#2516) --- app/src/main/java/com/keylesspalace/tusky/MainActivity.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 466dba167..25b70240c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -621,6 +621,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.mainToolbar.setOnClickListener { (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } + + updateProfiles() } private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { From d9c6269d4448e40a7a07ba6d209988b2b5eabe2c Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Thu, 12 May 2022 18:21:43 +0200 Subject: [PATCH 03/26] fix deleting media attachments removing the wrong ones (#2517) --- .../keylesspalace/tusky/components/compose/ComposeViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index abf2ff42c..7faf1139a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -184,7 +184,7 @@ class ComposeViewModel @Inject constructor( fun removeMediaFromQueue(item: QueuedMedia) { mediaToJob[item.localId]?.cancel() - media.update { mediaValue -> mediaValue.filter { it.localId == item.localId } } + media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } } } fun toggleMarkSensitive() { From 0a0f31451660f388edfa9ac9373a75549d6f8490 Mon Sep 17 00:00:00 2001 From: Ihor Hordiichuk Date: Thu, 12 May 2022 01:40:36 +0000 Subject: [PATCH 04/26] Translated using Weblate (Ukrainian) Currently translated at 100.0% (17 of 17 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/uk/ --- fastlane/metadata/android/uk/changelogs/91.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 fastlane/metadata/android/uk/changelogs/91.txt diff --git a/fastlane/metadata/android/uk/changelogs/91.txt b/fastlane/metadata/android/uk/changelogs/91.txt new file mode 100644 index 000000000..4132d1553 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Підтримка нових типів сповіщень Mastodon 3.5 +- Кращий вигляд позначки бота і розширений вибір тем +- Текст тепер можна вибрати у докладному поданні допису +- Виправлено безліч помилок, включно з тою, яка перешкоджала входу на Android 6 і старіших From 523c9b6b8ccc38a418a146229c39cf52abf10c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= Date: Thu, 12 May 2022 01:40:36 +0000 Subject: [PATCH 05/26] Translated using Weblate (Vietnamese) Currently translated at 100.0% (17 of 17 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/vi/ --- fastlane/metadata/android/vi/changelogs/91.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 fastlane/metadata/android/vi/changelogs/91.txt diff --git a/fastlane/metadata/android/vi/changelogs/91.txt b/fastlane/metadata/android/vi/changelogs/91.txt new file mode 100644 index 000000000..2835fdfcb --- /dev/null +++ b/fastlane/metadata/android/vi/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Hỗ trợ những kiểu thông báo mới của Mastodon 3.5 +- Nhãn của tài khoản nhìn đẹp hơn và thay đổi theo chủ đề +- Cho phép chọn và sao chép nội dung tút +- Sửa lỗi chặn đăng nhập trên Android 6 trở xuống From 354b07aa737fba8c39840ed31ec4bf81e939a9e6 Mon Sep 17 00:00:00 2001 From: Agee Kalisz Date: Fri, 13 May 2022 18:40:36 +0000 Subject: [PATCH 06/26] Translated using Weblate (Polish) Currently translated at 100.0% (479 of 479 strings) Co-authored-by: Agee Kalisz Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pl/ Translation: Tusky/Tusky --- app/src/main/res/values-pl/strings.xml | 50 +++++++++++++------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a1424b3a1..3fce8b6a5 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -20,7 +20,7 @@ Strona główna Powiadomienia Lokalne - Globalne + Sfederowane Wątek Wpisy Z odpowiedziami @@ -33,12 +33,12 @@ Edytuj profil Szkice Licencje - %s podbił - Wrażliwe treści - Ukryto zawartość multimedialną + %s podbite + Treści wrażliwe + Ukryto multimedia Naciśnij, aby wyświetlić Pokaż więcej - Ukryj + Pokaż mniej Pusto tutaj. Pociągnij, aby odświeżyć! %s podbił(-a) Twój wpis %s dodał Twój post do ulubionych @@ -95,13 +95,13 @@ Klawiatura emoji Pobieranie %1$s Skopiuj odnośnik - Udostępnij odnośnik do wpisu… + Udostępnij URL do… Udostępnij wpis do… Wyślij! Odblokowano użytkownika Cofnięto wyciszenie użytkownika Wyślij! - Pomyślnie wysłano odpowiedź. + Odpowiedź wysłano pomyślnie. Jaka instancja? Co Ci chodzi po głowie? Ostrzeżenie o zawartości @@ -151,7 +151,7 @@ Używaj niestandardowych kart Chrome Ukryj przycisk śledzenia podczas przewijania Filtrowanie osi czasu - Zakładki + Karty Pokaż podbicia Pokazuj odpowiedzi Pokazuj podgląd zawartości multimedialnej @@ -183,10 +183,10 @@ %1$s, %2$s, i %3$s %1$s i %2$s - %d nowe powiadomienie - %d nowe powiadomienia - %d nowych powiadomień - %d nowych powiadomień + %d nowa interakcja + %d nowe interakcje + %d nowych interakcji + %d nowych interakcji Konto zablokowane O programie @@ -404,25 +404,25 @@ Głosowanie w którym brałeś(-aś) udział zakończyła się Ankieta, którą stworzyłeś(aś), zakończyła się - Zostało %d dzień + Został %d dzień Zostało %d dni Zostało %d dni Zostało %d dni - Zostało %d godzina + Została %d godzina Zostało %d godziny Zostało %d godzin Zostało %d godzin - Zostało %d minuta + Została %d minuta Zostało %d minuty Zostało %d minut Zostało %d minut - Zostało %d sekunda + Została %d sekunda Zostało %d sekund Zostało %d sekund Zostało %d sekund @@ -462,7 +462,7 @@ Zakładki Dodaj do zakładek Zakładki - Dodane do zakładek + Dodany do zakładek Wybierz listę Lista Pliki audio muszą być mniejsze niż 40MB. @@ -493,8 +493,8 @@ Dół Góra - Nie możesz przesłać więcej niż %1$d załącznika. - Nie możesz przesłać więcej niż %1$d załączników. + Nie możesz przesłać więcej niż %1$d załącznik. + Nie możesz przesłać więcej niż %1$d załączniki. Nie możesz przesłać więcej niż %1$d załączników. Nie możesz przesłać więcej niż %1$d załączników. @@ -515,21 +515,21 @@ Włącz gest przesuwania by przełączać między zakładkami Załączniki Powiadomienia o prośbach o obserwowanie - ktoś kogo zasubskrybowałem/zasubskrybowałam opublikował nowy wpis + ktoś zasubskrybowany opublikował nowy wpis Wysłano prośbę o obserwowanie Ogłoszenia Zdrowie Anuluj subskrypcję Zasubskrybuj Mimo tego, że twoje konto nie jest zablokowane, administracja %1$s uznała, że możesz chcieć ręcznie przejrzeć te prośby o możliwość śledzenia od tych kont. - Wpis dla którego naszkicowałeś/naszkicowałaś odpowiedź został usunięty + Wpis dla którego naszkicowałeś/aś odpowiedź został usunięty Usunięto szkic Ukryj ilościowe statystyki na profilach Ukryj ilościowe statystyki na postach Przejrzyj powiadomienia Zapisano! Twoja prywatna notatka o tym koncie - Nieskończona + Nieograniczony Dźwięk Powiadomienia o opublikowaniu nowego wpisu przez kogoś, kogo obserwujesz Pozycja głównego paska nawigacji @@ -552,9 +552,11 @@ %s zarejestrował(a) się Rejestracje Powiadomienia o nowych użytkownikach - Powiadomienia o edycji wpisów z którymi interaktowałeś/aś + Powiadomienia o edycji wpisów z którymi dokonałeś/aś interakcji ktoś zarejestrował się - wpis, z którym interaktowałem/am został edytowany + wpis, z którym dokonałem/am interakcji został edytowany %s edytował(a) swój wpis Edycje wpisów + Zapisywanie szkicu… + Nie można załadować strony logowania. \ No newline at end of file From 6f515ad98a26a148ee2929ada665dbbf9e1b00e2 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 13 May 2022 18:40:36 +0000 Subject: [PATCH 07/26] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (479 of 479 strings) Co-authored-by: Eric Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/ Translation: Tusky/Tusky --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f3d7b2aaf..862cf06c9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -536,4 +536,5 @@ 嘟文编辑 当你进行过互动的嘟文被编辑时发出通知 无法加载登录页。 + 正在保存草稿… \ No newline at end of file From 47eabafed38f5435c9238bcabd8fbd5a0c5b259e Mon Sep 17 00:00:00 2001 From: Ihor Hordiichuk Date: Fri, 13 May 2022 18:40:36 +0000 Subject: [PATCH 08/26] Translated using Weblate (Ukrainian) Currently translated at 100.0% (479 of 479 strings) Co-authored-by: Ihor Hordiichuk Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/ Translation: Tusky/Tusky --- app/src/main/res/values-uk/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6c5100970..5998c9b07 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -550,4 +550,5 @@ Редакції допису Вхід Не вдалося завантажити сторінку входу. + Збереження чернетки… \ No newline at end of file From a6dc7ef425872bfe0fd4a3c14f996d2c829b8ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= Date: Fri, 13 May 2022 18:40:36 +0000 Subject: [PATCH 09/26] Translated using Weblate (Vietnamese) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (479 of 479 strings) Co-authored-by: Hồ Nhất Duy Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/ Translation: Tusky/Tusky --- app/src/main/res/values-vi/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 0c8109db4..bdf3af84b 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -517,4 +517,5 @@ Thông báo khi tút mà tôi tương tác bị sửa Đăng nhập Không thể tải trang đăng nhập. + Đang lưu nháp… \ No newline at end of file From 8fc2c1601eaa74c1b98eaacd8fcca32a266f7299 Mon Sep 17 00:00:00 2001 From: GunChleoc Date: Fri, 13 May 2022 18:40:37 +0000 Subject: [PATCH 10/26] Translated using Weblate (Gaelic) Currently translated at 100.0% (479 of 479 strings) Translated using Weblate (Gaelic) Currently translated at 100.0% (479 of 479 strings) Co-authored-by: GunChleoc Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/ Translation: Tusky/Tusky --- app/src/main/res/values-gd/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index e27b677aa..d01b0dca1 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -295,7 +295,7 @@ Cuir post air an sgeideal Faicsinneachd a’ phuist Postaichean air an sgeideal - Chuir %s am post agad ris na h-annsachdan + Is annsa le %s am post agad Bhrosnaich %s am post agad Postaichean air an sgeideal Snàithlean @@ -556,4 +556,5 @@ chaidh post a rinn mi conaltradh leis a deasachadh Clàraich a-steach Cha b’ urrainn dhuinn duilleag a’ chlàraidh a-steach fhosgladh. + A’ sàbhaladh na dreuchd… \ No newline at end of file From 8c6ccf426103b4ceac93b14d981f1f009f980225 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Fri, 13 May 2022 22:00:30 +0200 Subject: [PATCH 11/26] fix notification message formatting when username is not at the beginning of the message (#2522) * fix notification message formatting when username is not at the beginning of the message * search for placeholder in format string --- .../tusky/adapter/NotificationsAdapter.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index f681e64fd..327e4aa75 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -531,8 +531,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter { message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); String wholeMessage = String.format(format, displayName); final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); - str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + int displayNameIndex = format.indexOf("%s"); + str.setSpan( + new StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); CharSequence emojifiedText = CustomEmojiHelper.emojify( str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() ); From ec72cd0b52716e297ef256a75205f9129e642256 Mon Sep 17 00:00:00 2001 From: ButterflyOfFire Date: Tue, 17 May 2022 09:40:38 +0000 Subject: [PATCH 12/26] Translated using Weblate (French) Currently translated at 99.5% (477 of 479 strings) Co-authored-by: ButterflyOfFire Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/ Translation: Tusky/Tusky --- app/src/main/res/values-fr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index dc30483bd..d6b6e44ff 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -295,7 +295,7 @@ Mettre une légende Supprimer le média Verrouiller le compte - Vous devez approuvez manuellement les abonnements + Vous devez approuver manuellement les abonnements Enregistrer comme brouillon ? Envoi du pouet… Erreur lors de l’envoi du pouet From 725ce02ab1feb041cca51a62330c7b2d1e1a7416 Mon Sep 17 00:00:00 2001 From: hebbeff Date: Tue, 17 May 2022 09:40:38 +0000 Subject: [PATCH 13/26] Translated using Weblate (Chinese (Traditional)) Currently translated at 91.6% (439 of 479 strings) Co-authored-by: hebbeff Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hant/ Translation: Tusky/Tusky --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9fd135f40..02d7d2ed8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -525,4 +525,6 @@ 總是顯示被標注為內容警告的嘟文 搜尋失敗 帳號 + 登入 + 無法載入登入頁面。 \ No newline at end of file From 0bf71e642017382347042c1f84a764cc7b243b76 Mon Sep 17 00:00:00 2001 From: GunChleoc Date: Tue, 17 May 2022 09:40:38 +0000 Subject: [PATCH 14/26] Translated using Weblate (Gaelic) Currently translated at 100.0% (479 of 479 strings) Co-authored-by: GunChleoc Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/ Translation: Tusky/Tusky --- app/src/main/res/values-gd/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index d01b0dca1..317331dc8 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -92,7 +92,7 @@ Brathan nuair a dh’fhoillsich cuideigin air a rinn mi fo-sgrìobhadh post ùr Postaichean ùra dh’fhoillsich cuideigin air a rinn mi fo-sgrìobhadh post ùr - Tha %s air rud a phostadh + Phostaich %s rud Chan eil brath-fios ann. Brathan-fios Chaidh a shàbhaladh! From 74e139c11014772249fb7b85a24016555c3d2811 Mon Sep 17 00:00:00 2001 From: hebbeff Date: Tue, 17 May 2022 09:40:38 +0000 Subject: [PATCH 15/26] Translated using Weblate (Chinese (Simplified)) Currently translated at 88.2% (15 of 17 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/zh_Hans/ --- fastlane/metadata/android/zh-Hans/changelogs/83.txt | 3 +++ fastlane/metadata/android/zh-Hans/changelogs/87.txt | 8 ++++++++ 2 files changed, 11 insertions(+) create mode 100644 fastlane/metadata/android/zh-Hans/changelogs/83.txt create mode 100644 fastlane/metadata/android/zh-Hans/changelogs/87.txt diff --git a/fastlane/metadata/android/zh-Hans/changelogs/83.txt b/fastlane/metadata/android/zh-Hans/changelogs/83.txt new file mode 100644 index 000000000..e8f7c36e0 --- /dev/null +++ b/fastlane/metadata/android/zh-Hans/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +此版本修复了给图片添加标题时会崩溃的问题 diff --git a/fastlane/metadata/android/zh-Hans/changelogs/87.txt b/fastlane/metadata/android/zh-Hans/changelogs/87.txt new file mode 100644 index 000000000..06fcd290f --- /dev/null +++ b/fastlane/metadata/android/zh-Hans/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- 时间线加载逻辑完全重写,提升了流畅度、稳定性,更便于维护。 +- APNG和动画WebP格式的动态自定义表情符号。 +- 修正大量BUG +- 支持Android 11 +- 新增界面语言支持:苏格兰盖尔语、加利西亚语、乌克兰语 +- 改进翻译 From 20f3ec921f4040b1a1ca69ee89aa9f27b3561c2f Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Tue, 17 May 2022 19:24:17 +0200 Subject: [PATCH 16/26] Release 91 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2a34c80ae..34e941ecd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,8 +24,8 @@ android { applicationId APP_ID minSdkVersion 21 targetSdkVersion 31 - versionCode 90 - versionName "18.0 beta 1" + versionCode 91 + versionName "18.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true From 9ec5d6e3b032a582b6d95091e56122e80ae07c9c Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Tue, 17 May 2022 13:32:09 -0400 Subject: [PATCH 17/26] Push notifications support via UnifiedPush (#2303) Fixes #793. This is an implementation for push notifications based on UnifiedPush for Tusky. No push gateway (other than UP itself) is needed, since UnifiedPush is simple enough such that it can act as a catch-all endpoint for WebPush messages. When a UnifiedPush distributor is present on-device, we will by default register Tusky as a receiver; if no UnifiedPush distributor is available, then pull notifications are used as a fallback mechanism. Because WebPush messages are encrypted, and Mastodon does not send the keys and IV needed for decryption in the request body, for now the push handler simply acts as a trigger for the pre-existing NotificationWorker which is also used for pull notifications. Nevertheless, I have implemented proper key generation and storage, just in case we would like to implement full decryption support in the future when Mastodon upgrades to the latest WebPush encryption scheme that includes all information in the request body. For users with existing accounts, push notifications will not be enabled until all of the accounts have been re-logged in to grant the new push OAuth scope. A small prompt will be shown (until dismissed) as a Snackbar to explain to the user about this, and an option is added in Account Preferences to facilitate re-login without deleting local drafts and cache. --- app/build.gradle | 3 + .../36.json | 857 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 23 + .../com/keylesspalace/tusky/MainActivity.kt | 28 +- .../com/keylesspalace/tusky/SplashActivity.kt | 2 +- .../tusky/components/login/LoginActivity.kt | 28 +- .../notifications/NotificationHelper.java | 19 +- .../notifications/PushNotificationHelper.kt | 220 +++++ .../preference/AccountPreferencesFragment.kt | 15 + .../keylesspalace/tusky/db/AccountEntity.kt | 10 +- .../keylesspalace/tusky/db/AccountManager.kt | 18 +- .../keylesspalace/tusky/db/AppDatabase.java | 14 +- .../com/keylesspalace/tusky/di/AppModule.kt | 1 + .../tusky/di/BroadcastReceiverModule.kt | 8 + .../entity/NotificationSubscribeResult.kt | 24 + .../tusky/network/MastodonApi.kt | 30 + ...NotificationBlockStateBroadcastReceiver.kt | 67 ++ .../receiver/UnifiedPushBroadcastReceiver.kt | 80 ++ .../keylesspalace/tusky/util/CryptoUtil.kt | 60 ++ app/src/main/res/values/strings.xml | 7 + 20 files changed, 1490 insertions(+), 24 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt diff --git a/app/build.gradle b/app/build.gradle index 34e941ecd..2806a81fb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -181,6 +181,9 @@ dependencies { implementation "de.c1710:filemojicompat:$filemojicompat_version" implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version" + implementation "org.bouncycastle:bcprov-jdk15on:1.70" + implementation "com.github.UnifiedPush:android-connector:2.0.0" + testImplementation "androidx.test.ext:junit:1.1.3" testImplementation "org.robolectric:robolectric:4.4" testImplementation "org.mockito:mockito-inline:4.4.0" diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json new file mode 100644 index 000000000..d009a9e34 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json @@ -0,0 +1,857 @@ +{ + "formatVersion": 1, + "database": { + "version": 36, + "identityHash": "1b7461c291f67fe0b21f77b95de6a6be", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b7461c291f67fe0b21f77b95de6a6be')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3b0a977e3..b4969568c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -146,6 +146,29 @@ android:name=".receiver.SendStatusBroadcastReceiver" android:enabled="true" android:exported="false" /> + + + + + + + + + + + + + + + lifecycleScope.launch { + // Only disable UnifiedPush for this account -- do not call disableNotifications(), + // which unnecessarily disables it for all accounts and then re-enables it again at + // the next launch + disableUnifiedPushNotificationsForAccount(this@MainActivity, activeAccount) NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity) cacheUpdater.clearForUser(activeAccount.id) conversationRepository.deleteCacheForAccount(activeAccount.id) @@ -680,7 +682,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje NotificationHelper.disablePullNotifications(this@MainActivity) } val intent = if (newAccount == null) { - LoginActivity.getIntent(this@MainActivity, false) + LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT) } else { Intent(this@MainActivity, MainActivity::class.java) } @@ -714,6 +716,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje accountManager.updateActiveAccount(me) NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this) + // Setup push notifications + showMigrationNoticeIfNecessary(this, binding.root, accountManager) + if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { + lifecycleScope.launch { + enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) + } + } else { + disableAllNotifications(this, accountManager) + } + accountLocked = me.locked updateProfiles() diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt index 62f951626..f69aa5d19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt @@ -43,7 +43,7 @@ class SplashActivity : AppCompatActivity(), Injectable { val intent = if (accountManager.activeAccount != null) { Intent(this, MainActivity::class.java) } else { - LoginActivity.getIntent(this, false) + LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT) } startActivity(intent) finish() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index bcbb4abf8..8066482eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -92,12 +92,17 @@ class LoginActivity : BaseActivity(), Injectable { if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && - !isAdditionalLogin() + !isAdditionalLogin() && !isAccountMigration() ) { binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) } + if (isAccountMigration()) { + binding.domainEditText.setText(accountManager.activeAccount!!.domain) + binding.domainEditText.isEnabled = false + } + if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { Glide.with(binding.loginLogo) .load(BuildConfig.CUSTOM_LOGO_URL) @@ -120,7 +125,7 @@ class LoginActivity : BaseActivity(), Injectable { textView?.movementMethod = LinkMovementMethod.getInstance() } - if (isAdditionalLogin()) { + if (isAdditionalLogin() || isAccountMigration()) { setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayShowTitleEnabled(false) @@ -135,7 +140,7 @@ class LoginActivity : BaseActivity(), Injectable { override fun finish() { super.finish() - if (isAdditionalLogin()) { + if (isAdditionalLogin() || isAccountMigration()) { overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) } } @@ -230,7 +235,7 @@ class LoginActivity : BaseActivity(), Injectable { domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" ).fold( { accessToken -> - accountManager.addAccount(accessToken.accessToken, domain) + accountManager.addAccount(accessToken.accessToken, domain, OAUTH_SCOPES) val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -262,19 +267,28 @@ class LoginActivity : BaseActivity(), Injectable { } private fun isAdditionalLogin(): Boolean { - return intent.getBooleanExtra(LOGIN_MODE, false) + return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN + } + + private fun isAccountMigration(): Boolean { + return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION } companion object { private const val TAG = "LoginActivity" // logging tag - private const val OAUTH_SCOPES = "read write follow" + private const val OAUTH_SCOPES = "read write follow push" private const val LOGIN_MODE = "LOGIN_MODE" private const val DOMAIN = "domain" private const val CLIENT_ID = "clientId" private const val CLIENT_SECRET = "clientSecret" + const val MODE_DEFAULT = 0 + const val MODE_ADDITIONAL_LOGIN = 1 + // "Migration" is used to update the OAuth scope granted to the client + const val MODE_MIGRATION = 2 + @JvmStatic - fun getIntent(context: Context, mode: Boolean): Intent { + fun getIntent(context: Context, mode: Int): Intent { val loginIntent = Intent(context, LoginActivity::class.java) loginIntent.putExtra(LOGIN_MODE, mode) return loginIntent diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index 795868976..ce11ab1cc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -57,6 +57,7 @@ import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.PollOption; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver; import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver; import com.keylesspalace.tusky.util.StringUtils; @@ -539,13 +540,18 @@ public class NotificationHelper { } } - private static boolean filterNotification(AccountEntity account, Notification notification, + public static boolean filterNotification(AccountEntity account, Notification notification, + Context context) { + return filterNotification(account, notification.getType(), context); + } + + public static boolean filterNotification(AccountEntity account, Notification.Type type, Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - String channelId = getChannelId(account, notification); + String channelId = getChannelId(account, type); if(channelId == null) { // unknown notificationtype return false; @@ -554,7 +560,7 @@ public class NotificationHelper { return channel.getImportance() > NotificationManager.IMPORTANCE_NONE; } - switch (notification.getType()) { + switch (type) { case MENTION: return account.getNotificationsMentioned(); case STATUS: @@ -580,7 +586,12 @@ public class NotificationHelper { @Nullable private static String getChannelId(AccountEntity account, Notification notification) { - switch (notification.getType()) { + return getChannelId(account, notification.getType()); + } + + @Nullable + private static String getChannelId(AccountEntity account, Notification.Type type) { + switch (type) { case MENTION: return CHANNEL_MENTION + account.getIdentifier(); case STATUS: diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt new file mode 100644 index 000000000..ec2c82ac9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -0,0 +1,220 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +@file:JvmName("PushNotificationHelper") +package com.keylesspalace.tusky.components.notifications + +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.util.Log +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.preference.PreferenceManager +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.CryptoUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.unifiedpush.android.connector.UnifiedPush +import retrofit2.HttpException + +private const val TAG = "PushNotificationHelper" + +private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed" + +private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean = + accountManager.accounts.any(::accountNeedsMigration) + +private fun accountNeedsMigration(account: AccountEntity): Boolean = + !account.oauthScopes.contains("push") + +fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean = + accountManager.activeAccount?.let(::accountNeedsMigration) ?: false + +fun showMigrationNoticeIfNecessary(context: Context, parent: View, accountManager: AccountManager) { + // No point showing anything if we cannot enable it + if (!isUnifiedPushAvailable(context)) return + if (!anyAccountNeedsMigration(accountManager)) return + + val pm = PreferenceManager.getDefaultSharedPreferences(context) + if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return + + Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE).apply { + setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) } + show() + } +} + +private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) { + AlertDialog.Builder(context).apply { + if (currentAccountNeedsMigration(accountManager)) { + setMessage(R.string.dialog_push_notification_migration) + setPositiveButton(R.string.title_migration_relogin) { _, _ -> + context.startActivity(LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)) + } + } else { + setMessage(R.string.dialog_push_notification_migration_other_accounts) + } + setNegativeButton(R.string.action_dismiss) { dialog, _ -> + val pm = PreferenceManager.getDefaultSharedPreferences(context) + pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply() + dialog.dismiss() + } + show() + } +} + +private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { + if (isUnifiedPushNotificationEnabledForAccount(account)) { + // Already registered, update the subscription to match notification settings + updateUnifiedPushSubscription(context, api, accountManager, account) + } else { + UnifiedPush.registerAppWithDialog(context, account.id.toString()) + } +} + +fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) { + if (!isUnifiedPushNotificationEnabledForAccount(account)) { + // Not registered + return + } + + UnifiedPush.unregisterApp(context, account.id.toString()) +} + +fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean = + account.unifiedPushUrl.isNotEmpty() + +private fun isUnifiedPushAvailable(context: Context): Boolean = + UnifiedPush.getDistributors(context).isNotEmpty() + +fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean = + isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager) + +suspend fun enablePushNotificationsWithFallback(context: Context, api: MastodonApi, accountManager: AccountManager) { + if (!canEnablePushNotifications(context, accountManager)) { + // No UP distributors + NotificationHelper.enablePullNotifications(context) + return + } + + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + accountManager.accounts.forEach { + val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 || + nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false + val shouldEnable = it.notificationsEnabled && notificationGroupEnabled + + if (shouldEnable) { + enableUnifiedPushNotificationsForAccount(context, api, accountManager, it) + } else { + disableUnifiedPushNotificationsForAccount(context, it) + } + } +} + +private fun disablePushNotifications(context: Context, accountManager: AccountManager) { + accountManager.accounts.forEach { + disableUnifiedPushNotificationsForAccount(context, it) + } +} + +fun disableAllNotifications(context: Context, accountManager: AccountManager) { + disablePushNotifications(context, accountManager) + NotificationHelper.disablePullNotifications(context) +} + +private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = + buildMap { + Notification.Type.asList.forEach { + put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context)) + } + } + +// Called by UnifiedPush callback +suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity, endpoint: String) { + // Generate a prime256v1 key pair for WebPush + // Decryption is unimplemented for now, since Mastodon uses an old WebPush + // standard which does not send needed information for decryption in the payload + // This makes it not directly compatible with UnifiedPush + // As of now, we use it purely as a way to trigger a pull + val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) + val auth = CryptoUtil.secureRandomBytesEncoded(16) + + withContext(Dispatchers.IO) { + api.subscribePushNotifications( + "Bearer ${account.accessToken}", account.domain, + endpoint, keyPair.pubkey, auth, + buildSubscriptionData(context, account) + ).onFailure { + Log.d(TAG, "Error setting push endpoint for account ${account.id}") + Log.d(TAG, Log.getStackTraceString(it)) + Log.d(TAG, (it as HttpException).response().toString()) + + disableUnifiedPushNotificationsForAccount(context, account) + }.onSuccess { + Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") + + account.pushPubKey = keyPair.pubkey + account.pushPrivKey = keyPair.privKey + account.pushAuth = auth + account.pushServerKey = it.serverKey + account.unifiedPushUrl = endpoint + accountManager.saveAccount(account) + } + } +} + +// Synchronize the enabled / disabled state of notifications with server-side subscription +suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { + withContext(Dispatchers.IO) { + api.updatePushNotificationSubscription( + "Bearer ${account.accessToken}", account.domain, + buildSubscriptionData(context, account) + ).onSuccess { + Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") + + account.pushServerKey = it.serverKey + accountManager.saveAccount(account) + } + } +} + +suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { + withContext(Dispatchers.IO) { + api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) + .onFailure { + Log.d(TAG, "Error unregistering push endpoint for account " + account.id) + Log.d(TAG, Log.getStackTraceString(it)) + Log.d(TAG, (it as HttpException).response().toString()) + } + .onSuccess { + Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) + // Clear the URL in database + account.unifiedPushUrl = "" + account.pushServerKey = "" + account.pushAuth = "" + account.pushPrivKey = "" + account.pushPubKey = "" + accountManager.saveAccount(account) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index e6bf83fbc..ff4380d32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -23,6 +23,7 @@ import androidx.annotation.DrawableRes import androidx.preference.PreferenceFragmentCompat import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.R @@ -30,6 +31,8 @@ import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.instancemute.InstanceListActivity +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable @@ -139,6 +142,18 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + if (currentAccountNeedsMigration(accountManager)) { + preference { + setTitle(R.string.title_migration_relogin) + setIcon(R.drawable.ic_logout) + setOnPreferenceClickListener { + val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + true + } + } + } + preferenceCategory(R.string.pref_publishing) { listPreference { setTitle(R.string.pref_default_post_privacy) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 400eb0730..0b717120c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -64,7 +64,15 @@ data class AccountEntity( var activeNotifications: String = "[]", var emojis: List = emptyList(), var tabPreferences: List = defaultTabs(), - var notificationsFilter: String = "[\"follow_request\"]" + var notificationsFilter: String = "[\"follow_request\"]", + // Scope cannot be changed without re-login, so store it in case + // the scope needs to be changed in the future + var oauthScopes: String = "", + var unifiedPushUrl: String = "", + var pushPubKey: String = "", + var pushPrivKey: String = "", + var pushAuth: String = "", + var pushServerKey: String = "", ) { val identifier: String diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 3de34f55e..2ddbe5223 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -54,7 +54,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { * @param accessToken the access token for the new account * @param domain the domain of the accounts Mastodon instance */ - fun addAccount(accessToken: String, domain: String) { + fun addAccount(accessToken: String, domain: String, oauthScopes: String) { activeAccount?.let { it.isActive = false @@ -65,7 +65,10 @@ class AccountManager @Inject constructor(db: AppDatabase) { val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 val newAccountId = maxAccountId + 1 - activeAccount = AccountEntity(id = newAccountId, domain = domain.lowercase(Locale.ROOT), accessToken = accessToken, isActive = true) + activeAccount = AccountEntity( + id = newAccountId, domain = domain.lowercase(Locale.ROOT), + accessToken = accessToken, oauthScopes = oauthScopes, isActive = true + ) } /** @@ -189,4 +192,15 @@ class AccountManager @Inject constructor(db: AppDatabase) { id == accountId } } + + /** + * Finds an account by its string identifier + * @param identifier the string identifier of the account + * @return the requested account or null if it was not found + */ + fun getAccountByIdentifier(identifier: String): AccountEntity? { + return accounts.find { + identifier == it.identifier + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index d5f023e58..25fe1a61f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 35) + }, version = 36) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -541,4 +541,16 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT"); } }; + + public static final Migration MIGRATION_35_36 = new Migration(35, 36) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 0861e9cf0..85741762d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -64,6 +64,7 @@ class AppModule { AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35, + AppDatabase.MIGRATION_35_36, ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt index b7213fa64..e071fc84b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt @@ -16,8 +16,10 @@ package com.keylesspalace.tusky.di +import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver +import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver import dagger.Module import dagger.android.ContributesAndroidInjector @@ -28,4 +30,10 @@ abstract class BroadcastReceiverModule { @ContributesAndroidInjector abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver + + @ContributesAndroidInjector + abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver + + @ContributesAndroidInjector + abstract fun contributeNotificationBlockStateBroadcastReceiver(): NotificationBlockStateBroadcastReceiver } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt new file mode 100644 index 000000000..c6eb09bec --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt @@ -0,0 +1,24 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class NotificationSubscribeResult( + val id: Int, + val endpoint: String, + @SerializedName("server_key") val serverKey: String, +) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 7357293b5..ef81ed117 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MediaUploadResult import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.NotificationSubscribeResult import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.ScheduledStatus @@ -47,6 +48,7 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.Field +import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.HTTP @@ -597,4 +599,32 @@ interface MastodonApi { @Path("id") accountId: String, @Field("comment") note: String ): Single + + @FormUrlEncoded + @POST("api/v1/push/subscription") + suspend fun subscribePushNotifications( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Field("subscription[endpoint]") endPoint: String, + @Field("subscription[keys][p256dh]") keysP256DH: String, + @Field("subscription[keys][auth]") keysAuth: String, + // The "data[alerts][]" fields to enable / disable notifications + // Should be generated dynamically from all the available notification + // types defined in [com.keylesspalace.tusky.entities.Notification.Types] + @FieldMap data: Map + ): Result + + @FormUrlEncoded + @PUT("api/v1/push/subscription") + suspend fun updatePushNotificationSubscription( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @FieldMap data: Map + ): Result + + @DELETE("api/v1/push/subscription") + suspend fun unsubscribePushNotifications( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + ): Result } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt new file mode 100644 index 000000000..20b18a9f9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -0,0 +1,67 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.receiver + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications +import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount +import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.network.MastodonApi +import dagger.android.AndroidInjection +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@DelicateCoroutinesApi +class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { + @Inject + lateinit var mastodonApi: MastodonApi + + @Inject + lateinit var accountManager: AccountManager + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + if (Build.VERSION.SDK_INT < 28) return + if (!canEnablePushNotifications(context, accountManager)) return + + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val gid = when (intent.action) { + NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> { + val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID) + nm.getNotificationChannel(channelId).group + } + NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED -> { + intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID) + } + else -> null + } ?: return + + accountManager.getAccountByIdentifier(gid)?.let { account -> + if (isUnifiedPushNotificationEnabledForAccount(account)) { + // Update UnifiedPush notification subscription + GlobalScope.launch { updateUnifiedPushSubscription(context, mastodonApi, accountManager, account) } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt new file mode 100644 index 000000000..45a5ae2b6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -0,0 +1,80 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.receiver + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.keylesspalace.tusky.components.notifications.NotificationWorker +import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint +import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.network.MastodonApi +import dagger.android.AndroidInjection +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.unifiedpush.android.connector.MessagingReceiver +import javax.inject.Inject + +@DelicateCoroutinesApi +class UnifiedPushBroadcastReceiver : MessagingReceiver() { + companion object { + const val TAG = "UnifiedPush" + } + + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var mastodonApi: MastodonApi + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + AndroidInjection.inject(this, context) + } + + override fun onMessage(context: Context, message: ByteArray, instance: String) { + AndroidInjection.inject(this, context) + Log.d(TAG, "New message received for account $instance") + val workManager = WorkManager.getInstance(context) + val request = OneTimeWorkRequest.from(NotificationWorker::class.java) + workManager.enqueue(request) + } + + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + AndroidInjection.inject(this, context) + Log.d(TAG, "Endpoint available for account $instance: $endpoint") + accountManager.getAccountById(instance.toLong())?.let { + // Launch the coroutine in global scope -- it is short and we don't want to lose the registration event + // and there is no saner way to use structured concurrency in a receiver + GlobalScope.launch { registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) } + } + } + + override fun onRegistrationFailed(context: Context, instance: String) = Unit + + override fun onUnregistered(context: Context, instance: String) { + AndroidInjection.inject(this, context) + Log.d(TAG, "Endpoint unregistered for account $instance") + accountManager.getAccountById(instance.toLong())?.let { + // It's fine if the account does not exist anymore -- that means it has been logged out + GlobalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt new file mode 100644 index 000000000..f4fa4b5ba --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt @@ -0,0 +1,60 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.util.Base64 +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.interfaces.ECPrivateKey +import org.bouncycastle.jce.interfaces.ECPublicKey +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.KeyPairGenerator +import java.security.SecureRandom +import java.security.Security + +object CryptoUtil { + const val CURVE_PRIME256_V1 = "prime256v1" + + private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + + init { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.addProvider(BouncyCastleProvider()) + } + + private fun secureRandomBytes(len: Int): ByteArray { + val ret = ByteArray(len) + SecureRandom.getInstance("SHA1PRNG").nextBytes(ret) + return ret + } + + fun secureRandomBytesEncoded(len: Int): String { + return Base64.encodeToString(secureRandomBytes(len), BASE64_FLAGS) + } + + data class EncodedKeyPair(val pubkey: String, val privKey: String) + + fun generateECKeyPair(curve: String): EncodedKeyPair { + val spec = ECNamedCurveTable.getParameterSpec(curve) + val gen = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) + gen.initialize(spec) + val keyPair = gen.genKeyPair() + val pubKey = keyPair.public as ECPublicKey + val privKey = keyPair.private as ECPrivateKey + val encodedPubKey = Base64.encodeToString(pubKey.q.getEncoded(false), BASE64_FLAGS) + val encodedPrivKey = Base64.encodeToString(privKey.d.toByteArray(), BASE64_FLAGS) + return EncodedKeyPair(encodedPubKey, encodedPrivKey) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d8ce83941..0d6712a7b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,7 @@ Muted users Blocked users Hidden domains + Re-login for push notifications Follow Requests Edit your profile Drafts @@ -147,6 +148,8 @@ Open boost author Show boosts Show favorites + Dismiss + Details Hashtags Mentions @@ -642,4 +645,8 @@ Compose Post Saving draft… + Re-login all accounts to enable push notification support. + In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in Account Preferences will preserve all of your local drafts and cache. + You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support. + From df49851042fe69d051f74807f9bd3fe2113f604a Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 17 May 2022 19:37:09 +0200 Subject: [PATCH 18/26] never collapse tabs in SearchActivity (#2505) --- app/src/main/res/layout/activity_search.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml index 566e995c6..96abd34ed 100644 --- a/app/src/main/res/layout/activity_search.xml +++ b/app/src/main/res/layout/activity_search.xml @@ -16,8 +16,8 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" - app:contentInsetStartWithNavigation="0dp" android:elevation="@dimen/actionbar_elevation" + app:contentInsetStartWithNavigation="0dp" app:layout_scrollFlags="scroll|snap|enterAlways" app:navigationIcon="?attr/homeAsUpIndicator" /> @@ -27,8 +27,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:tabGravity="fill" + app:tabMaxWidth="0dp" app:tabMode="fixed" - app:tabTextAppearance="@style/TuskyTabAppearance"/> + app:tabTextAppearance="@style/TuskyTabAppearance" /> @@ -38,6 +39,6 @@ android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> - + \ No newline at end of file From d97493d312932b9db2d041d7481087297d229beb Mon Sep 17 00:00:00 2001 From: Martin Marconcini Date: Tue, 17 May 2022 19:49:42 +0200 Subject: [PATCH 19/26] Issue 2477: Show account's creation date in Profile. (#2480) * Show account's creation date in Profile. * Fix broken test. * Store account creation date in the Database. * Reformat and reposition Joined Date according to PR Feedback. * Revert "Store account creation date in the Database." This reverts commit d9761f53 as it's not needed. * Change Account's Creation Date to a java.util.Date. Update Test. * Fix wildcard import. * Show full month instead of an abbreviation. * Remove `lazy` usage in favor of local instantiation. Co-authored-by: Martin Marconcini Co-authored-by: Konrad Pozniak --- .../components/account/AccountActivity.kt | 18 ++++++++++ .../com/keylesspalace/tusky/entity/Account.kt | 1 + app/src/main/res/layout/activity_account.xml | 15 +++++++- app/src/main/res/values/strings.xml | 4 +++ .../tusky/ComposeActivityTest.kt | 35 ++++++++++--------- 5 files changed, 56 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 6a8bc7dc6..7b5b8d7d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -84,6 +84,9 @@ import com.keylesspalace.tusky.view.showMuteAccountDialog import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import java.text.NumberFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale import javax.inject.Inject import kotlin.math.abs @@ -413,6 +416,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI updateToolbar() updateMovedAccount() updateRemoteAccount() + updateAccountJoinedDate() updateAccountStats() invalidateOptionsMenu() @@ -422,6 +426,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } } + private fun updateAccountJoinedDate() { + loadedAccount?.let { account -> + try { + binding.accountDateJoined.text = resources.getString( + R.string.account_date_joined, + SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(account.createdAt) + ) + binding.accountDateJoined.visibility = View.VISIBLE + } catch (e: ParseException) { + binding.accountDateJoined.visibility = View.GONE + } + } + } + /** * Load account's avatar and header image */ diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index bf5431ee6..4870c188b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -23,6 +23,7 @@ data class Account( @SerializedName("username") val localUsername: String, @SerializedName("acct") val username: String, @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract + @SerializedName("created_at") val createdAt: Date, val note: String, val url: String, val avatar: String, diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 31b37ad89..ff548dfca 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -235,6 +235,19 @@ tools:itemCount="2" tools:listitem="@layout/item_account_field" /> + + Unsubscribe Compose Post + + Joined %1$s + Saving draft… Re-login all accounts to enable push notification support. In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in Account Preferences will preserve all of your local drafts and cache. You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support. + diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index 3a8f2f23e..95d1bae0e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -47,6 +47,8 @@ import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.fakes.RoboMenuItem +import java.util.Date +import kotlin.collections.HashMap /** * Created by charlag on 3/7/18. @@ -466,22 +468,23 @@ class ComposeActivityTest { null, listOf("en"), Account( - "1", - "admin", - "admin", - "admin", - "", - "https://example.token", - "", - "", - false, - 0, - 0, - 0, - null, - false, - emptyList(), - emptyList() + id = "1", + localUsername = "admin", + username = "admin", + displayName = "admin", + createdAt = Date(), + note = "", + url = "https://example.token", + avatar = "", + header = "", + locked = false, + statusesCount = 0, + followersCount = 0, + followingCount = 0, + source = null, + bot = false, + emojis = emptyList(), + fields = emptyList(), ), maximumLegacyTootCharacters, null, From 4c9cd4084b4969affc60c4c69a427d101a70a0c5 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 17 May 2022 19:55:26 +0200 Subject: [PATCH 20/26] show list title when viewing list timeline (#2503) --- app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt | 7 ++++--- .../java/com/keylesspalace/tusky/StatusListActivity.kt | 6 ++++-- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-bg/strings.xml | 1 - app/src/main/res/values-bn-rBD/strings.xml | 1 - app/src/main/res/values-bn-rIN/strings.xml | 1 - app/src/main/res/values-ca/strings.xml | 1 - app/src/main/res/values-ckb/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 1 - app/src/main/res/values-cy/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-eo/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-eu/strings.xml | 1 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-ga/strings.xml | 1 - app/src/main/res/values-gd/strings.xml | 1 - app/src/main/res/values-gl/strings.xml | 1 - app/src/main/res/values-hi/strings.xml | 1 - app/src/main/res/values-hu/strings.xml | 1 - app/src/main/res/values-is/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-no-rNB/strings.xml | 1 - app/src/main/res/values-oc/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt-rPT/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-sa/strings.xml | 1 - app/src/main/res/values-sl/strings.xml | 1 - app/src/main/res/values-sv/strings.xml | 1 - app/src/main/res/values-ta/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-uk/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rHK/strings.xml | 1 - app/src/main/res/values-zh-rMO/strings.xml | 1 - app/src/main/res/values-zh-rSG/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 46 files changed, 9 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index f2f7b38e7..f1e17a516 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -198,9 +198,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { ).show() } - private fun onListSelected(listId: String) { + private fun onListSelected(listId: String, listTitle: String) { startActivityWithSlideInAnimation( - StatusListActivity.newListIntent(this, listId) + StatusListActivity.newListIntent(this, listId, listTitle) ) } @@ -270,7 +270,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { override fun onClick(v: View) { if (v == itemView) { - onListSelected(getItem(bindingAdapterPosition).id) + val list = getItem(bindingAdapterPosition) + onListSelected(list.id, list.title) } else { onMore(getItem(bindingAdapterPosition), v) } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 82604022d..5ae1591c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -46,7 +46,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { Kind.FAVOURITES -> getString(R.string.title_favourites) Kind.BOOKMARKS -> getString(R.string.title_bookmarks) Kind.TAG -> getString(R.string.title_tag).format(hashtag) - else -> getString(R.string.title_list_timeline) + else -> intent.getStringExtra(EXTRA_LIST_TITLE) } supportActionBar?.run { @@ -73,6 +73,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private const val EXTRA_KIND = "kind" private const val EXTRA_LIST_ID = "id" + private const val EXTRA_LIST_TITLE = "title" private const val EXTRA_HASHTAG = "tag" fun newFavouritesIntent(context: Context) = @@ -85,10 +86,11 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) } - fun newListIntent(context: Context, listId: String) = + fun newListIntent(context: Context, listId: String, listTitle: String) = Intent(context, StatusListActivity::class.java).apply { putExtra(EXTRA_KIND, Kind.LIST.name) putExtra(EXTRA_LIST_ID, listId) + putExtra(EXTRA_LIST_TITLE, listTitle) } @JvmStatic diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d0160a600..c3f1c8e08 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -269,7 +269,6 @@ إضافة حساب ماستدون جديد القوائم القوائم - الخط الزمني للقائمة لا يمكن إنشاء قائمة لا يمكن إعادة تسمية القائمة لا يمكن حذف القائمة diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 092db2183..a58d3eaf8 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -151,7 +151,6 @@ Списъкът не можа да се изтрие Списъкът не можа да се създаде Списъкът не можа да се преименува - Списъчна емисия Списъци Списъци Добавяне на нов Mastodon акаунт diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 7d87e3ed8..331e5e189 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -75,7 +75,6 @@ তালিকা মুছে ফেলা যায়নি তালিকা নামকরণ করা যায়নি তালিকা তৈরি করা যায়নি - তালিকা টাইমলাইনে রাখুন তালিকাসমূহ তালিকাসমূহ নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 839e59698..93f68dbd8 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -275,7 +275,6 @@ নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন তালিকাসমূহ তালিকাসমূহ - তালিকা টাইমলাইনে রাখুন তালিকা তৈরি করা যায়নি তালিকা নামকরণ করা যায়নি তালিকা মুছে ফেলা যায়নি diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 65affe75e..ddba8b510 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -275,7 +275,6 @@ Afegir un compte de Mastodont Llistes Llistes - Cronologia de la llista És impossible crear la llista Impossible reanomenar la llista És impossible suprimir la llista diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index ba275b0b4..985ec63a5 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -403,7 +403,6 @@ نەیتوانی لیستەکە بسڕێتەوە نەیتوانی ناوی لیست بنووسرێ نەیتوانی لیست دروست بکات - لیستی تایم لاین لیستەکان لیستەکان زیادکردنی ئەژمێری ماتۆدۆنی نوێ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b045aafee..bd74ce631 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -274,7 +274,6 @@ Přidat nový účet Mastodon Seznamy Seznamy - Časová osa seznamu Nelze vytvořit seznam Nelze přejmenovat seznam Nelze smazat seznam diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 789863950..7d68e1e09 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -236,7 +236,6 @@ Ychwanegu cyfrif Mastodon newydd Rhestri Rhestri - Amserlen rhestri Yn postio â chyfrif %1$s Methu gosod pennawd Pennu pennawd diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c8d647500..3b3399318 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -256,7 +256,6 @@ Neues Mastodon-Konto hinzufügen Listen Listen - Liste Liste erstellen Liste umbenennen Liste löschen diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index c0cfef4bb..3f20878e9 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -271,7 +271,6 @@ Aldoni novan Mastodon konton Listoj Listoj - Tempolinio de la listo Ne povis krei la liston Ne povis ŝanĝi la nomon de la listo Ne povis forigi la liston diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index db50c17ea..1f90c79e3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -251,7 +251,6 @@ Añadir cuenta de Mastodon Listas Listas - Cronología de lista Publicando con la cuenta %1$s Error al añadir leyenda diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 19ac2ebde..e8e1605ac 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -235,7 +235,6 @@ Mastodon kontua gehitu Zerrendak Zerrendak - Zerrenda denbora-lerroa %1$s kontuarekin tut egiten Akatsa deskribapena eranstean diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 31ae0f9a7..90d643faa 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -229,7 +229,6 @@ افزودن حساب ماستودون جدید فهرست‌ها فهرست‌ها - خط زمانی فهرست در حال فرستادن با حساب %1$s شکست در تنظیم عنوان diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d6b6e44ff..b36cfa70c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -275,7 +275,6 @@ Ajouter un nouveau compte Mastodon Listes Listes - Fil de la liste Impossible de créer la liste Impossible de renommer la liste Impossible de supprimer la liste diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 2ea95dc97..382566c0a 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -310,7 +310,6 @@ Theip ar stádas a fháil Seolfar an tuarascáil chuig do mhodhnóir freastalaí. Féadfaidh tú míniú a thabhairt ar an bhfáth go bhfuil tú ag tuairisciú an chuntais seo thíos: Cuir Cuntas Mastodon nua leis - Liostaigh amlíne Níorbh fhéidir liosta a chruthú Níorbh fhéidir an liosta a athainmniú Níorbh fhéidir an liosta a scriosadh diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 317331dc8..39e7d5a32 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -270,7 +270,6 @@ Cha b’ urrainn dhuinn an liosta a sguabadh às Cha b’ urrainn dhut ainm ùr a thoirt air an liosta Cha b’ urrainn dhuinn an liosta a chruthachadh - Loidhne-ama na liosta Cuir cunntas Mastodon ùr ris Cuir cunntas ris An abairt ri chriathradh diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 5b488df36..2b6be45eb 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -275,7 +275,6 @@ Non se puido eliminar a listaxe Non se puido renomear a listaxe Non se puido crear a listaxe - Cronoloxía da listaxe Listaxes Listaxes Engadir unha nova conta Mastodon diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 2ecee5413..dba9309b0 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -248,7 +248,6 @@ लिखने को सुरक्षित करें\? खाता लॉक करें कैप्शन सेट करें - सूची टाइमलाइन खाता जोड़ो पूरा शब्द फ़िल्टर संपादित करें diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 31911d657..887c821fc 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -336,7 +336,6 @@ %dmp múlva Teljes szó Ha a kulcsszó csak alfanumerikus karakterekből áll, csak teljes szóra fog illeszkedni - Lista idővonal Általad követettek keresése Fiók hozzáadása a listához Fiók eltávolítása a listából diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 14a38b275..0177ee5c1 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -288,7 +288,6 @@ Frasi sem á að sía Bæta við aðgang Bæta við nýjum Mastodon-aðgangi - Lista upp tímalínu Ekki tókst að búa til lista Ekki tókst að endurnefna lista Ekki tókst að eyða lista diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 162846af5..7b775bebe 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -269,7 +269,6 @@ Aggiungi un nuovo Account Mastodon Liste Liste - Timeline della lista Non è stato possibile creare la lista Non è stato possibile rinominare la lista Non è stato possibile eliminare la lista diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 179b7e8bb..f5fcb6fe9 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -255,7 +255,6 @@ 新しいMastodonアカウントを追加 リスト リスト - リストタイムライン リスト名を変更できませんでした リスト名の変更 %1$sで投稿 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fc81f2d3f..e8143bb95 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -282,7 +282,6 @@ 마스토돈 계정을 추가합니다 리스트 리스트 - 리스트 타임라인 리스트를 만들 수 없습니다. 리스트의 이름을 변경할 수 없습니다. 리스트를 삭제할 수 없습니다. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fc3c17051..7339b238d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -259,7 +259,6 @@ Een nieuw Mastodonaccount toevoegen Lijsten Lijsten - Tijdlijn lijst Aan het publiceren met account %1$s Toevoegen van beschrijving mislukt diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index 80b744bb5..044da640c 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -242,7 +242,6 @@ Legg til ny Mastodon-konto Lister Lister - Listetidslinje Kunne ikke opprette liste Kunne ikke gi liste nytt navn Kunne ikke slette liste diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index b7b6624fd..c2115ac1d 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -228,7 +228,6 @@ Apondre un nòu compte Mastodon Listas Listas - Flux de la lista Publicar amb lo compte %1$s Fracàs en apondre una legenda Apondre una legenda diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3fce8b6a5..044c57ed4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -229,7 +229,6 @@ Dodaj nowe Konto Mastodon Listy Listy - Oś czasu listy Publikowanie z konta %1$s Nie udało się ustawić podpisu Ustaw podpis diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0cd82676a..2e53d6c13 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -246,7 +246,6 @@ Adicionar nova conta Mastodon Listas Listas - Linha da lista Usando a conta %1$s Erro ao incluir descrição Descrever diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 6be06b0c2..fa4b458e3 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -325,7 +325,7 @@ Listas Não foi possível renomear a lista Listas - Cronologia da timeline + Não foi possível criar a lista Não foi possível apagar a lista Criar uma lista diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2d49aac5f..98cf06234 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -296,7 +296,6 @@ Добавить новый акканут Mastodon Списки Списки - Список лент Не удалось создать список Не удалось переименовать список Не удалось удалить список diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 32b651bf9..e304b1657 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -202,7 +202,6 @@ पुनः सूचिनामकरणं कर्तुमशक्यम् सूचिनिर्माणं कर्तुमशक्यम् अनुसरणानुरोधो नश्यताम् \? - सूचेः समयतालिका सूचयः सूचयः नवमास्टोडोनलेखा युज्यताम् diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 4aa29e3d9..caa491019 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -247,7 +247,6 @@ Dodaj nov Mastodon račun Seznami Seznami - Seznam časovnice Seznama ni bilo mogoče ustvariti Seznama ni bilo mogoče preimenovati Seznama ni bilo mogoče izbrisati diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 5ee0f0868..5e8a011aa 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -269,7 +269,6 @@ Lägg till ett nytt Mastodon-konto Listor Listor - Lista tidslinje Kunde inte skapa lista Kunde inte byta namn på lista Kunde inte radera lista diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 3bedb002a..7604eca58 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -216,7 +216,6 @@ புதிய Mastodon கணக்கைச் சேர்க்க பட்டியல்கள் பட்டியல்கள் - காலவரிசை பட்டியல் %1$s கணக்குடன் பதிவிட தலைப்பை அமைக்க முடியவில்லை தலைப்பை அமை diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 98e6ab1e1..e094542c9 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -144,7 +144,6 @@ ไม่สามารถลบรายการได้ ไม่สามารถเปลี่ยนชื่อรายการได้ ไม่สามารถสร้างรายการได้ - ไทม์ไลน์ในรายการ เพิ่มบัญชี Mastodon ใหม่ เพิ่มบัญชี วลีที่ต้องการกรอง diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b097977a3..e4bb815f3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -243,7 +243,6 @@ Yeni Mastodon hesabı ekle Listeler Listeler - Zaman çizelgesini listele %1$s hesabıyla gönderiliyor Görsel engelli için tanımla diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5998c9b07..6e6924a58 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -285,7 +285,6 @@ Не вдалося видалити список Не вдалося перейменувати список Не вдалося створити список - Стрічка списку Додати новий обліковий запис Mastodon Додати обліковий запис Фільтрувати фразу diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index bdf3af84b..6788526a4 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -448,7 +448,6 @@ Xóa danh sách Đổi tên danh sách Tạo danh sách - Danh sách bảng tin Thêm tài khoản Mastodon Thêm tài khoản Thêm mô tả diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 862cf06c9..eb1d8559c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -277,7 +277,6 @@ 添加新的 Mastodon 帐号 列表 列表 - 列表时间轴 无法新建列表 无法重命名列表 无法删除列表 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 89bd90de9..5d3779f23 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -276,7 +276,6 @@ 加入新的 Mastodon 帳號 列表 列表 - 列表時間軸 無法新建列表 無法重命名列表 無法刪除列表 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index 288af1b3c..20c258a5e 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -270,7 +270,6 @@ 加入新的 Mastodon 帳號 列表 列表 - 列表時間軸 無法新建列表 無法重命名列表 無法刪除列表 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index bc3775973..6e603e50f 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -274,7 +274,6 @@ 添加新的 Mastodon 帐号 列表 列表 - 列表时间轴 无法新建列表 无法重命名列表 无法删除列表 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 02d7d2ed8..6ccd6b8e2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -276,7 +276,6 @@ 加入新的 Mastodon 帳號 列表 列表 - 列表時間軸 無法新建列表 無法重命名列表 無法刪除列表 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31af8bd94..630d67dd8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -385,7 +385,6 @@ Lists Lists - List timeline Could not create list Could not rename list Could not delete list From cec8f6dd65aa01588e47c7fdc170048cdb002166 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 17 May 2022 19:55:37 +0200 Subject: [PATCH 21/26] modernize autocomplete (#2510) * modernize autocomplete * use @WorkerThread annotation --- .../components/compose/ComposeActivity.kt | 4 +- .../compose/ComposeAutoCompleteAdapter.java | 320 ------------------ .../compose/ComposeAutoCompleteAdapter.kt | 175 ++++++++++ .../compose}/ComposeTokenizer.kt | 2 +- .../components/compose/ComposeViewModel.kt | 60 ++-- .../tusky/network/MastodonApi.kt | 18 + .../tusky/util/CallExtensions.kt | 23 ++ .../res/layout/item_autocomplete_account.xml | 89 +++-- .../res/layout/item_autocomplete_divider.xml | 5 - .../res/layout/item_autocomplete_emoji.xml | 18 +- .../res/layout/item_autocomplete_hashtag.xml | 10 +- .../tusky/ComposeTokenizerTest.kt | 2 +- 12 files changed, 314 insertions(+), 412 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt rename app/src/main/java/com/keylesspalace/tusky/{util => components/compose}/ComposeTokenizer.kt (98%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt delete mode 100644 app/src/main/res/layout/item_autocomplete_divider.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index a6e8d6776..57723c721 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -78,7 +78,6 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.ComposeTokenizer import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.afterTextChanged @@ -307,7 +306,8 @@ class ComposeActivity : ComposeAutoCompleteAdapter( this, preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) ) ) binding.composeEditField.setTokenizer(ComposeTokenizer()) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java deleted file mode 100644 index 8a4f0ce1f..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java +++ /dev/null @@ -1,320 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.components.compose; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.Filter; -import android.widget.Filterable; -import android.widget.ImageView; -import android.widget.TextView; - -import com.bumptech.glide.Glide; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.HashTag; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; - -import java.util.ArrayList; -import java.util.List; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Created by charlag on 12/11/17. - */ - -public class ComposeAutoCompleteAdapter extends BaseAdapter - implements Filterable { - private static final int ACCOUNT_VIEW_TYPE = 1; - private static final int HASHTAG_VIEW_TYPE = 2; - private static final int EMOJI_VIEW_TYPE = 3; - private static final int SEPARATOR_VIEW_TYPE = 0; - - private final ArrayList resultList; - private final AutocompletionProvider autocompletionProvider; - private final boolean animateAvatar; - private final boolean animateEmojis; - - public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) { - super(); - resultList = new ArrayList<>(); - this.autocompletionProvider = autocompletionProvider; - this.animateAvatar = animateAvatar; - this.animateEmojis = animateEmojis; - } - - @Override - public int getCount() { - return resultList.size(); - } - - @Override - public AutocompleteResult getItem(int index) { - return resultList.get(index); - } - - @Override - public long getItemId(int position) { - return position; - } - - @Override - @NonNull - public Filter getFilter() { - return new Filter() { - @Override - public CharSequence convertResultToString(Object resultValue) { - if (resultValue instanceof AccountResult) { - return formatUsername(((AccountResult) resultValue)); - } else if (resultValue instanceof HashtagResult) { - return formatHashtag((HashtagResult) resultValue); - } else if (resultValue instanceof EmojiResult) { - return formatEmoji((EmojiResult) resultValue); - } else { - return ""; - } - } - - // This method is invoked in a worker thread. - @Override - protected FilterResults performFiltering(CharSequence constraint) { - FilterResults filterResults = new FilterResults(); - if (constraint != null) { - List results = - autocompletionProvider.search(constraint.toString()); - filterResults.values = results; - filterResults.count = results.size(); - } - return filterResults; - } - - @SuppressWarnings("unchecked") - @Override - protected void publishResults(CharSequence constraint, FilterResults results) { - if (results != null && results.count > 0) { - resultList.clear(); - resultList.addAll((List) results.values); - notifyDataSetChanged(); - } else { - notifyDataSetInvalidated(); - } - } - }; - } - - @Override - @NonNull - public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { - View view = convertView; - final Context context = parent.getContext(); - - switch (getItemViewType(position)) { - case ACCOUNT_VIEW_TYPE: - AccountViewHolder accountViewHolder; - if (convertView == null) { - view = ((LayoutInflater) context - .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_autocomplete_account, parent, false); - } - if (view.getTag() == null) { - view.setTag(new AccountViewHolder(view)); - } - accountViewHolder = (AccountViewHolder) view.getTag(); - - AccountResult accountResult = ((AccountResult) getItem(position)); - if (accountResult != null) { - TimelineAccount account = accountResult.account; - String formattedUsername = context.getString( - R.string.post_username_format, - account.getUsername() - ); - accountViewHolder.username.setText(formattedUsername); - CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), - account.getEmojis(), accountViewHolder.displayName, animateEmojis); - accountViewHolder.displayName.setText(emojifiedName); - - int avatarRadius = accountViewHolder.avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_42dp); - - ImageLoadingHelper.loadAvatar( - account.getAvatar(), - accountViewHolder.avatar, - avatarRadius, - animateAvatar - ); - } - break; - - case HASHTAG_VIEW_TYPE: - if (convertView == null) { - view = ((LayoutInflater) context - .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_autocomplete_hashtag, parent, false); - } - - HashtagResult result = (HashtagResult) getItem(position); - if (result != null) { - ((TextView) view).setText(formatHashtag(result)); - } - break; - - case EMOJI_VIEW_TYPE: - EmojiViewHolder emojiViewHolder; - if (convertView == null) { - view = ((LayoutInflater) context - .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_autocomplete_emoji, parent, false); - } - if (view.getTag() == null) { - view.setTag(new EmojiViewHolder(view)); - } - emojiViewHolder = (EmojiViewHolder) view.getTag(); - - EmojiResult emojiResult = ((EmojiResult) getItem(position)); - if (emojiResult != null) { - Emoji emoji = emojiResult.emoji; - String formattedShortcode = context.getString( - R.string.emoji_shortcode_format, - emoji.getShortcode() - ); - emojiViewHolder.shortcode.setText(formattedShortcode); - Glide.with(emojiViewHolder.preview) - .load(emoji.getUrl()) - .into(emojiViewHolder.preview); - } - break; - - case SEPARATOR_VIEW_TYPE: - if (convertView == null) { - view = ((LayoutInflater) context - .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_autocomplete_divider, parent, false); - } - break; - default: - throw new AssertionError("unknown view type"); - } - - return view; - } - - private static String formatUsername(AccountResult result) { - return String.format("@%s", result.account.getUsername()); - } - - private static String formatHashtag(HashtagResult result) { - return String.format("#%s", result.hashtag); - } - - private static String formatEmoji(EmojiResult result) { - return String.format(":%s:", result.emoji.getShortcode()); - } - - @Override - public int getViewTypeCount() { - return 4; - } - - @Override - public int getItemViewType(int position) { - AutocompleteResult item = getItem(position); - - if (item instanceof AccountResult) { - return ACCOUNT_VIEW_TYPE; - } else if (item instanceof HashtagResult) { - return HASHTAG_VIEW_TYPE; - } else if (item instanceof EmojiResult) { - return EMOJI_VIEW_TYPE; - } else { - return SEPARATOR_VIEW_TYPE; - } - } - - @Override - public boolean areAllItemsEnabled() { - // there may be separators - return false; - } - - @Override - public boolean isEnabled(int position) { - return !(getItem(position) instanceof ResultSeparator); - } - - public abstract static class AutocompleteResult { - AutocompleteResult() { - } - } - - public final static class AccountResult extends AutocompleteResult { - private final TimelineAccount account; - - public AccountResult(TimelineAccount account) { - this.account = account; - } - } - - public final static class HashtagResult extends AutocompleteResult { - private final String hashtag; - - public HashtagResult(HashTag hashtag) { - this.hashtag = hashtag.getName(); - } - } - - public final static class EmojiResult extends AutocompleteResult { - private final Emoji emoji; - - public EmojiResult(Emoji emoji) { - this.emoji = emoji; - } - } - - public final static class ResultSeparator extends AutocompleteResult {} - - public interface AutocompletionProvider { - List search(String mention); - } - - private class AccountViewHolder { - final TextView username; - final TextView displayName; - final ImageView avatar; - - private AccountViewHolder(View view) { - username = view.findViewById(R.id.username); - displayName = view.findViewById(R.id.display_name); - avatar = view.findViewById(R.id.avatar); - } - } - - private class EmojiViewHolder { - final TextView shortcode; - final ImageView preview; - - private EmojiViewHolder(View view) { - shortcode = view.findViewById(R.id.shortcode); - preview = view.findViewById(R.id.preview); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt new file mode 100644 index 000000000..e825798cf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt @@ -0,0 +1,175 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.Filter +import android.widget.Filterable +import androidx.annotation.WorkerThread +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding +import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding +import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.visible + +class ComposeAutoCompleteAdapter( + private val autocompletionProvider: AutocompletionProvider, + private val animateAvatar: Boolean, + private val animateEmojis: Boolean, + private val showBotBadge: Boolean +) : BaseAdapter(), Filterable { + + private var resultList: List = emptyList() + + override fun getCount() = resultList.size + + override fun getItem(index: Int): AutocompleteResult { + return resultList[index] + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun getFilter(): Filter { + return object : Filter() { + + override fun convertResultToString(resultValue: Any): CharSequence { + return when (resultValue) { + is AutocompleteResult.AccountResult -> formatUsername(resultValue) + is AutocompleteResult.HashtagResult -> formatHashtag(resultValue) + is AutocompleteResult.EmojiResult -> formatEmoji(resultValue) + else -> "" + } + } + + @WorkerThread + override fun performFiltering(constraint: CharSequence?): FilterResults { + val filterResults = FilterResults() + if (constraint != null) { + val results = autocompletionProvider.search(constraint.toString()) + filterResults.values = results + filterResults.count = results.size + } + return filterResults + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + if (results.count > 0) { + resultList = results.values as List + notifyDataSetChanged() + } else { + notifyDataSetInvalidated() + } + } + } + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val itemViewType = getItemViewType(position) + val context = parent.context + + val view: View = convertView ?: run { + val layoutInflater = LayoutInflater.from(context) + val binding = when (itemViewType) { + ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater) + HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater) + EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater) + else -> throw AssertionError("unknown view type") + } + binding.root.tag = binding + binding.root + } + + when (val binding = view.tag) { + is ItemAutocompleteAccountBinding -> { + val accountResult = getItem(position) as AutocompleteResult.AccountResult + val account = accountResult.account + binding.username.text = context.getString(R.string.post_username_format, account.username) + binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis) + val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) + loadAvatar( + account.avatar, + binding.avatar, + avatarRadius, + animateAvatar + ) + binding.avatarBadge.visible(showBotBadge && account.bot) + } + is ItemAutocompleteHashtagBinding -> { + val result = getItem(position) as AutocompleteResult.HashtagResult + binding.root.text = formatHashtag(result) + } + is ItemAutocompleteEmojiBinding -> { + val emojiResult = getItem(position) as AutocompleteResult.EmojiResult + val (shortcode, url) = emojiResult.emoji + binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode) + Glide.with(binding.preview) + .load(url) + .into(binding.preview) + } + } + return view + } + + override fun getViewTypeCount() = 3 + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE + is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE + is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE + } + } + + sealed class AutocompleteResult { + class AccountResult(val account: TimelineAccount) : AutocompleteResult() + + class HashtagResult(val hashtag: String) : AutocompleteResult() + + class EmojiResult(val emoji: Emoji) : AutocompleteResult() + } + + interface AutocompletionProvider { + fun search(token: String): List + } + + companion object { + private const val ACCOUNT_VIEW_TYPE = 0 + private const val HASHTAG_VIEW_TYPE = 1 + private const val EMOJI_VIEW_TYPE = 2 + + private fun formatUsername(result: AutocompleteResult.AccountResult): String { + return String.format("@%s", result.account.username) + } + + private fun formatHashtag(result: AutocompleteResult.HashtagResult): String { + return String.format("#%s", result.hashtag) + } + + private fun formatEmoji(result: AutocompleteResult.EmojiResult): String { + return String.format(":%s:", result.emoji.shortcode) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt index 6fee42edf..7b3d208b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.util +package com.keylesspalace.tusky.components.compose import android.text.SpannableString import android.text.Spanned diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 7faf1139a..2c0da5833 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository @@ -38,6 +39,7 @@ import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.randomAlphanumericString +import com.keylesspalace.tusky.util.result import com.keylesspalace.tusky.util.toLiveData import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.Dispatchers @@ -51,7 +53,6 @@ import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.rxSingle import kotlinx.coroutines.withContext -import java.util.Locale import javax.inject.Inject class ComposeViewModel @Inject constructor( @@ -330,48 +331,39 @@ class ComposeViewModel @Inject constructor( return true } - fun searchAutocompleteSuggestions(token: String): List { + fun searchAutocompleteSuggestions(token: String): List { when (token[0]) { '@' -> { - return try { - api.searchAccounts(query = token.substring(1), limit = 10) - .blockingGet() - .map { ComposeAutoCompleteAdapter.AccountResult(it) } - } catch (e: Throwable) { - Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) - emptyList() - } + return api.searchAccountsCall(query = token.substring(1), limit = 10) + .result() + .fold({ accounts -> + accounts.map { AutocompleteResult.AccountResult(it) } + }, { e -> + Log.e(TAG, "Autocomplete search for $token failed.", e) + emptyList() + }) } '#' -> { - return try { - api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) - .blockingGet() - .hashtags - .map { ComposeAutoCompleteAdapter.HashtagResult(it) } - } catch (e: Throwable) { - Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) - emptyList() - } + return api.searchCall(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) + .result() + .fold({ searchResult -> + searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) } + }, { e -> + Log.e(TAG, "Autocomplete search for $token failed.", e) + emptyList() + }) } ':' -> { val emojiList = emoji.value ?: return emptyList() + val incomplete = token.substring(1) - val incomplete = token.substring(1).lowercase(Locale.ROOT) - val results = ArrayList() - val resultsInside = ArrayList() - for (emoji in emojiList) { - val shortcode = emoji.shortcode.lowercase(Locale.ROOT) - if (shortcode.startsWith(incomplete)) { - results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) - } else if (shortcode.indexOf(incomplete, 1) != -1) { - resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) - } + return emojiList.filter { emoji -> + emoji.shortcode.contains(incomplete, ignoreCase = true) + }.sortedBy { emoji -> + emoji.shortcode.indexOf(incomplete, ignoreCase = true) + }.map { emoji -> + AutocompleteResult.EmojiResult(emoji) } - if (results.isNotEmpty() && resultsInside.isNotEmpty()) { - results.add(ComposeAutoCompleteAdapter.ResultSeparator()) - } - results.addAll(resultsInside) - return results } else -> { Log.w(TAG, "Unexpected autocompletion token: $token") diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index ef81ed117..3a34169c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -288,6 +288,14 @@ interface MastodonApi { @Query("following") following: Boolean? = null ): Single> + @GET("api/v1/accounts/search") + fun searchAccountsCall( + @Query("q") query: String, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("following") following: Boolean? = null + ): Call> + @GET("api/v1/accounts/{id}") fun account( @Path("id") accountId: String @@ -593,6 +601,16 @@ interface MastodonApi { @Query("following") following: Boolean? = null ): Single + @GET("api/v2/search") + fun searchCall( + @Query("q") query: String?, + @Query("type") type: String? = null, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + @Query("following") following: Boolean? = null + ): Call + @FormUrlEncoded @POST("api/v1/accounts/{id}/note") fun updateAccountNote( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt new file mode 100644 index 000000000..809dcd2b4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt @@ -0,0 +1,23 @@ +package com.keylesspalace.tusky.util + +import retrofit2.Call +import retrofit2.HttpException + +/** + * Synchronously executes the call and returns the response encapsulated in a kotlin.Result. + * Since Result is an inline class it is not possible to do this with a Retrofit adapter unfortunately. + * More efficient then calling a suspending method with runBlocking + */ +fun Call.result(): Result { + return try { + val response = execute() + val responseBody = response.body() + if (response.isSuccessful && responseBody != null) { + Result.success(responseBody) + } else { + Result.failure(HttpException(response)) + } + } catch (e: Exception) { + Result.failure(e) + } +} diff --git a/app/src/main/res/layout/item_autocomplete_account.xml b/app/src/main/res/layout/item_autocomplete_account.xml index 681f9919f..000bae534 100644 --- a/app/src/main/res/layout/item_autocomplete_account.xml +++ b/app/src/main/res/layout/item_autocomplete_account.xml @@ -1,48 +1,65 @@ - + android:paddingStart="16dp" + android:paddingTop="8dp" + android:paddingEnd="16dp" + android:paddingBottom="8dp"> - + - + - + - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_autocomplete_divider.xml b/app/src/main/res/layout/item_autocomplete_divider.xml deleted file mode 100644 index f9b211b03..000000000 --- a/app/src/main/res/layout/item_autocomplete_divider.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_emoji.xml b/app/src/main/res/layout/item_autocomplete_emoji.xml index 2f9100402..fbc2f5c98 100644 --- a/app/src/main/res/layout/item_autocomplete_emoji.xml +++ b/app/src/main/res/layout/item_autocomplete_emoji.xml @@ -5,24 +5,24 @@ android:layout_height="wrap_content" android:gravity="center_vertical" android:orientation="horizontal" - android:padding="8dp"> + tools:ignore="UseCompoundDrawables"> + android:layout_marginStart="16dp" + android:layout_marginTop="8dp" + android:layout_marginBottom="8dp" + android:importantForAccessibility="no" /> + tools:text="#Tusky" /> diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt index e203dde27..fa0bba94e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt @@ -15,7 +15,7 @@ package com.keylesspalace.tusky -import com.keylesspalace.tusky.util.ComposeTokenizer +import com.keylesspalace.tusky.components.compose.ComposeTokenizer import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith From 9a84d14f19ce135e99cc1b7eaf2a59f2c7ed0b26 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 17 May 2022 19:55:46 +0200 Subject: [PATCH 22/26] add app category to AndroidManifest (#2513) --- app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4969568c..48a5e622f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ Date: Tue, 17 May 2022 19:55:58 +0200 Subject: [PATCH 23/26] update dagger to 2.42 (#2523) --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 2806a81fb..abf88a96a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,7 +98,7 @@ ext.roomVersion = '2.4.2' ext.retrofitVersion = '2.9.0' ext.okhttpVersion = '4.9.3' ext.glideVersion = '4.13.1' -ext.daggerVersion = '2.41' +ext.daggerVersion = '2.42' ext.materialdrawerVersion = '8.4.5' ext.emoji2_version = '1.1.0' ext.filemojicompat_version = '3.2.1' From bdd94d43c57294b1df03857842696740f2fa6629 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 17 May 2022 19:56:12 +0200 Subject: [PATCH 24/26] update android material to 1.6.0 (#2524) --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index abf88a96a..0b41eb704 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -134,7 +134,7 @@ dependencies { kapt "androidx.room:room-compiler:$roomVersion" implementation 'androidx.core:core-splashscreen:1.0.0-beta02' - implementation "com.google.android.material:material:1.5.0" + implementation "com.google.android.material:material:1.6.0" implementation "com.google.code.gson:gson:2.9.0" From 53cca00e8cbbed930dd3a841cdc33f71ac0a4b32 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 17 May 2022 19:56:21 +0200 Subject: [PATCH 25/26] update Android Gradle plugin to 7.2.0 (#2525) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3a5251fa0..f6b9d7ceb 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { gradlePluginPortal() } dependencies { - classpath "com.android.tools.build:gradle:7.1.2" + classpath "com.android.tools.build:gradle:7.2.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20" classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" } From 51f3794e78ea6e9da633ad623646410daf9c1092 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 17 May 2022 20:15:37 +0200 Subject: [PATCH 26/26] update Kotlin to 1.6.21 (#2526) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f6b9d7ceb..725ab8da4 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { classpath "com.android.tools.build:gradle:7.2.0" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21" classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" } }