From 70da63c764ee643d89d97e0ae19b593fc0fc57fe Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 01/29] Translated using Weblate (Turkish) Currently translated at 90.7% (369 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/ Translated using Weblate (Italian) Currently translated at 96.8% (394 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/ Translated using Weblate (Esperanto) Currently translated at 96.8% (394 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/eo/ Translated using Weblate (French) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/ Translated using Weblate (Arabic) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ar/ --- app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-eo/strings.xml | 3 +- app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 78 +++++++++++++++++++++++--- app/src/main/res/values-tr/strings.xml | 35 +++++++++++- 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b166f8d17..29078170d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -495,4 +495,6 @@ الفواصل المرجعية مدعوم بِـ Tusky أضيف إلى الفواصل المرجعية + اختر قائمة + القائمة diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 74d976381..1053072f0 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -456,4 +456,5 @@ Elekton %d Redaktigi - +Legosignoj + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 16411dfdb..86021d009 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -480,4 +480,6 @@ Marquer comme signet Signets Marqué comme un signet + Sélectionner la liste + Liste diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 87e46e9d2..4b3db7c1d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -325,9 +325,9 @@ Non fissare Fissa - <b>%1$s</b> Mi piace - <b>%1$s</b> Mi piace - + %1$s Mi piace + %1$s Mi piace + <b>%s</b> Boost <b>%s</b> Boost @@ -392,9 +392,9 @@ Domini nascosti Domini nascosti Silenzia %s - %s + %s mostrati - Sei sicuro di voler bloccare tutto %s\? Non vedrai nessun contenuto da quel dominio in nessuna timeline pubblica o nelle tue notifiche. I tuoi seguaci che stanno in quel dominio saranno rimossi + Sei sicuro di voler bloccare tutto %s\? Non vedrai nessun contenuto da quel dominio in nessuna timeline pubblica o nelle tue notifiche. I tuoi seguaci che stanno in quel dominio saranno rimossi. Nascondi l\'intero dominio Le votazioni sono finite @@ -405,7 +405,71 @@ Parola intera - Quando la parola chiave o la frase sono composte da caratteri alfanumerici, sara\' applicata solo se corrisponde alla parola completa + Quando la parola chiave o la frase sono composte da soli caratteri alfanumerici, sarà applicata solo se corrisponde alla parola completa Insieme di emoji di Google - + Segnalibri + Segnalibro + Modifica + Segnalibri + Aggiungi sondaggio + Fatto con Tusky + Espandi sempre i toot segnalati come contenuto sensibile + Messo nei segnalibri + Sondaggio con scelte: %1$s, %2$s, %3$s, %4$s; %5$s + + Scegli lista + Lista + Azioni per l\'immagine %s + + Un sondaggio che hai votato è terminato + Un sondaggio che hai creato è terminato + + + %d giorno + %d giorni + + + %d ora + %d ore + + + %d minuto + %d minuti + + + %d secondo + %d secondi + + + Continua + Indietro + Fatto + Inviato con successo @%s + Altri commenti + Inoltra a %s + Errore durante l\'invio + Errore durante lo scaricamento degli aggiornamenti + La segnalazione sarà inviata al moderatore del tuo server. Puoi spiegare perchè vuoi segnalare questo utente qui sotto: + L\'utente è su un altro server. Mandare una copia della segnalazione anche lì\? + Utenti + Errore durante la ricerca + + Mostra il filtro delle notifiche + + + Sondaggio + 5 minuti + 30 minuti + 1 ora + 6 ore + 1 giorno + 3 giorni + 7 giorni + Aggiungi scelta + Scelte multiple + Scelta %d + Modifica + Errore nella ricerca del post %s + + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 68ecea80a..4d6a71ac4 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -294,8 +294,8 @@ <b>%1$s</b> Favoriler - <b>%s</b> Yinelenen - <b>%s</b> Yinelenenler + <b>%s</b> Yinelenen + <b>%s</b> Yinelenenler tarafından yinelendi Tarafından favorilendi @@ -425,4 +425,35 @@ Bahsedenler #%d medyayı aç + Yer imleri + Zamanlanmış iletiler + Yerimi + Düzenle + Sil ve düzenle + Yer imleri + Anket ekle + Zamanlanmış iletiler + İleti zamanla + Sıfırla + Zamanlanmış iletiyi yapılandırmak için buraya dokunun. + Bu iletiyi silip yeniden düzenlemek istiyor musun\? + Botlar için gösterge göster + Tusky tarafından desteklenmektedir + Yerimine eklendi + Liste seç + Liste + Hesaplar + Arama başarısız + + Anket + 5 dakika + 30 dakika + 1 saat + 6 saat + 1 gün + 3 gün + 7 gün + Seçenek ekle + Çoklu seçim + Düzenle From fb2c028c1b3b5c86b92a1c48c3eed37a5fdba3da Mon Sep 17 00:00:00 2001 From: Vegard Skjefstad Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 02/29] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegian?= =?UTF-8?q?=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nb_NO/ --- app/src/main/res/values-no-rNB/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index c59dc1055..2b786c319 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -515,4 +515,6 @@ Bokmerke Bokmerker Bokmerke lagt til + Velg liste + Liste From e8db046806636bef327730a8206c991d44b4d19b Mon Sep 17 00:00:00 2001 From: knuxify Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 03/29] Translated using Weblate (Polish) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pl/ --- app/src/main/res/values-pl/strings.xml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index fbb66ae8d..df69f180d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -368,9 +368,9 @@ Przypnij do profilu - <b>%1$s</b> polubienie - <b>%1$s</b> polubienia - <b>%1$s</b> polubień + <b>%1$s</b> polubienie + <b>%1$s</b> polubienia + <b>%1$s</b> polubień @@ -492,4 +492,6 @@ Zakładka Zakładki Dodane do zakładek + Wybierz listę + Lista From bcb6f5b11b2ef3a2c5d0823b64dcd90e36a7a90b Mon Sep 17 00:00:00 2001 From: Daniele Lira Mereb Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 04/29] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pt_BR/ Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 97ff90f68..9626a86ed 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -191,9 +191,9 @@ Grande Maior - Novas Menções + Menções Notificar sobre novas menções - Novos Seguidores + Seguidores Notificar sobre novos seguidores Boosts Notificar quando derem boost nos seus toots @@ -342,7 +342,7 @@ Remover conta da lista Descrever para deficientes visuais -\n(limite de %d caracteres) +\n(até %d caracteres) CC-BY 4.0 CC-BY-SA 4.0 @@ -467,17 +467,19 @@ Opção %d Editar -Toots agendados +Agendados Editar - Toots agendados + Agendados Agendar toot Cancelar Toque aqui para agendar Erro ao pesquisar %s Salvos - Salvo + Salvar Salvos Desenvolvido por Tusky Salvo + Selecionar lista + Lista From ee78230330551f74427fa2802013efc452e228f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Gera?= Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 05/29] Translated using Weblate (Hungarian) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/ --- app/src/main/res/values-hu/strings.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 6e0a1c3b3..5197d5974 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -473,4 +473,11 @@ Ide nyúlj az időzített tülkök beállításához. Nem találjuk ezt a posztot %s - +Könyvjelzők + Könyvjelző + Könyvjelzők + Tusky által hatjva + Könyvjelzőzve + Lista kiválasztása + Lista + From 0f0b4ed275fb138df1e9923775d7f517c0446977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quent=C3=AD?= Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 06/29] Translated using Weblate (Occitan) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/ --- app/src/main/res/values-oc/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 1497f8ecb..b731af9c9 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -485,4 +485,6 @@ Ajustar als marcapaginas Marcapaginas Ajustat als marcapaginas + Seleccionar la list + Lista From ed11eaa9b1511e1870b0035149e703d6c7b1bb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9lanie=20Chauvel?= Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 07/29] Translated using Weblate (French) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/ Translated using Weblate (Esperanto) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/eo/ --- app/src/main/res/values-eo/strings.xml | 16 +++++++++++++++- app/src/main/res/values-fr/strings.xml | 16 ++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 1053072f0..cacefd8db 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -457,4 +457,18 @@ Redaktigi Legosignoj - + Planitaj mesaĝoj + Aldoni al legosignoj + Redakti + Legosignoj + Planitaj mesaĝoj + Plani mesaĝon + Restarigi + Frapetu ĉi-tie por agordi la planitan mesaĝon. + Funkciigita de Tusky + Aldonita al la legosignoj + Elekti la liston + Listo + Eraro dum elserĉo de la mesaĝo %s + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 86021d009..88be37d92 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -26,7 +26,7 @@ Messages directs Onglets Pouet - Pouets + Messages Pouets & réponses Épinglés Abonnements @@ -170,8 +170,8 @@ Me notifier lorsque on me mentionne on me suit - mes pouets sont boostés - mes pouets sont mis en favoris + mes messages sont boostés + mes messages sont mis en favoris Apparence Thème de l’application Fils chronologiques @@ -473,13 +473,13 @@ Planifier le pouet Réinitialiser Appuyez ici pour configurer le pouet planifié. - Erreur lors de la recherche du post %s + Erreur lors de la récupération du message %s Propulsé par Tusky - Signets - Marquer comme signet - Signets - Marqué comme un signet + Marque-pages + Ajouter aux marque-pages + Marque-pages + Ajouté aux marque-pages Sélectionner la liste Liste From 5b5f8937962d9f0ce1ed5cd081cb34f22a518516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesc=20Gal=C3=AD?= Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 08/29] Translated using Weblate (Catalan) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ca/ --- app/src/main/res/values-ca/strings.xml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 903613785..6027dbe1b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -518,4 +518,19 @@ Afegeix una tria Múltiples tries Tria %d - + Preferits + Toots programats + Preferit + Editar + Preferits + Toots programats + Programar el toot + Reiniciar + Clica aquí per configurar el toot programat. + Desenvolupat per Tusky + Afegit a les adreces d\'interès + Seleccionar la llista + Llista + S\'ha produït un error en cercar la publicació %s + + From b08a3c7202ecc3de81210d8d3a5fae45263b2a80 Mon Sep 17 00:00:00 2001 From: simevo Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 09/29] Translated using Weblate (Italian) Currently translated at 96.8% (394 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/ --- app/src/main/res/values-it/strings.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4b3db7c1d..4329dbaa3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -472,4 +472,10 @@ Modifica Errore nella ricerca del post %s - +Toot programmati + Toot programmati + Programma un toot + RIpristina + Tocca qui configurare i toot programmati. + %1$s • %2$s + From 66392c90a0fd6dfbea573c0285885d959f6de334 Mon Sep 17 00:00:00 2001 From: Muha Aliss Date: Sat, 14 Dec 2019 23:39:35 +0000 Subject: [PATCH 10/29] Translated using Weblate (Turkish) Currently translated at 90.7% (369 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/ --- app/src/main/res/values-tr/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4d6a71ac4..bc7c8d352 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -50,7 +50,7 @@ Burada hiçbir şey yok. Burada henüz hiçbir şey yok. Yenilemek için aşağıya çekin! %s iletini yineledi - %s iletini favorilerine ekledi + %s ileti favorilerine ekledi %s seni takip etti \@%s bildir Daha fazla yorum? @@ -456,4 +456,6 @@ Seçenek ekle Çoklu seçim Düzenle + \@%s olarak yanıtla + Bot From 0bb89b0e609c68d8ed7ca69d3ea46440eedf0dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesc=20Gal=C3=AD?= Date: Wed, 11 Dec 2019 08:13:39 +0000 Subject: [PATCH 11/29] Translated using Weblate (Catalan) Currently translated at 100.0% (7 of 7 strings) Translation: Tusky/Tusky-app Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/ca/ --- fastlane/metadata/android/ca/changelogs/58.txt | 5 ++--- fastlane/metadata/android/ca/changelogs/67.txt | 9 +++++++++ fastlane/metadata/android/ca/changelogs/68.txt | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/ca/changelogs/67.txt create mode 100644 fastlane/metadata/android/ca/changelogs/68.txt diff --git a/fastlane/metadata/android/ca/changelogs/58.txt b/fastlane/metadata/android/ca/changelogs/58.txt index 835551122..3e76f92f1 100644 --- a/fastlane/metadata/android/ca/changelogs/58.txt +++ b/fastlane/metadata/android/ca/changelogs/58.txt @@ -1,10 +1,9 @@ -670/5000 Tusky v6.0 - Els filtres de línia de temps s'han canviat a Preferències del compte i es sincronitzaran amb el servidor - Ara podeu tenir un hashtag personalitzat com a pestanya a la interfície principal - Ara es poden editar llistes -- Seguretat: es va suprimir el suport per a TLS 1.0 i TLS 1.1, i es va afegir suport per a TLS 1.3 a Android 6+ +- Seguretat: es va suprimir el suport per a TLS 1.0 i TLS 1.1 i es va afegir suport per a TLS 1.3 a Android 6+ - La vista de redacció ara suggerirà emojis personalitzats en començar a escriure - Configuració nova del tema "seguir el tema del sistema" -- Mil +- Millora de l’ diff --git a/fastlane/metadata/android/ca/changelogs/67.txt b/fastlane/metadata/android/ca/changelogs/67.txt new file mode 100644 index 000000000..6b039ef73 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/67.txt @@ -0,0 +1,9 @@ +Tusky v9.0 + +- Ara podeu crear enquestes a partir de Tusky +- Millora de la cerca +- Nova opció a Preferències del compte per ampliar sempre els avisos de contingut +- Els avatars del calaix de navegació tenen ara una forma quadrada arrodonida +- Ara és possible informar als usuaris fins i tot quan mai no han publicat un estat +- Ara Tusky es negarà a connectar-se a connexions de text clar a Android 6+ +- Un munt d’altres petites millores i solucions d’errors diff --git a/fastlane/metadata/android/ca/changelogs/68.txt b/fastlane/metadata/android/ca/changelogs/68.txt new file mode 100644 index 000000000..81ca68dc9 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/68.txt @@ -0,0 +1,3 @@ +Tusky v9.1 + +Aquesta versió garanteix la compatibilitat amb Mastodon 3 i millora el rendiment i l'estabilitat. From dedf1758bb1d8ef49c3835b07dafd9478f574fef Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 14 Dec 2019 23:39:29 +0000 Subject: [PATCH 12/29] Translated using Weblate (Italian) Currently translated at 42.9% (3 of 7 strings) Translation: Tusky/Tusky-app Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/it/ --- fastlane/metadata/android/it/changelogs/58.txt | 8 ++++++++ fastlane/metadata/android/it/changelogs/61.txt | 7 +++++++ fastlane/metadata/android/it/changelogs/67.txt | 9 +++++++++ fastlane/metadata/android/it/changelogs/68.txt | 3 +++ fastlane/metadata/android/it/full_description.txt | 12 ++++++++++++ fastlane/metadata/android/it/short_description.txt | 1 + fastlane/metadata/android/it/title.txt | 1 + 7 files changed, 41 insertions(+) create mode 100644 fastlane/metadata/android/it/changelogs/58.txt create mode 100644 fastlane/metadata/android/it/changelogs/61.txt create mode 100644 fastlane/metadata/android/it/changelogs/67.txt create mode 100644 fastlane/metadata/android/it/changelogs/68.txt create mode 100644 fastlane/metadata/android/it/full_description.txt create mode 100644 fastlane/metadata/android/it/short_description.txt create mode 100644 fastlane/metadata/android/it/title.txt diff --git a/fastlane/metadata/android/it/changelogs/58.txt b/fastlane/metadata/android/it/changelogs/58.txt new file mode 100644 index 000000000..08c03baac --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/58.txt @@ -0,0 +1,8 @@ +Tusky v6.0 + +- I filtri della timeline sono stati spostati in Preferenze Utente e si sincronizzeranno con il server +- Ora è possibile avere un hashtag personalizzato come scheda nell'interfaccia principale +- Le liste possono ora essere modificate +- Sicurezza: rimosso il supporto per TLS 1.0 e TLS 1.1.1, e aggiunto il supporto per TLS 1.3 su Android 6+. +- La vista della composizione suggerirà ora le emojis personalizzate quando si inizia a digitare +- Nuova impostazione del tema "Segui il tema del diff --git a/fastlane/metadata/android/it/changelogs/61.txt b/fastlane/metadata/android/it/changelogs/61.txt new file mode 100644 index 000000000..748d37919 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/61.txt @@ -0,0 +1,7 @@ +Tusky v7.0 + +- Supporto per la visualizzazione di sondaggi, voti e relative notifiche +- Nuovi bottoni per filtrare le notifiche ed eliminarle tutte +- Cancella e riscrivi i tuoi toots +- Nuovo indicatore che mostra sull'immagine del profilo se un account è un bot (può essere disattivato nelle preferenze) +- Nuove traduzioni: Norvegese Bokmål e sloveno. diff --git a/fastlane/metadata/android/it/changelogs/67.txt b/fastlane/metadata/android/it/changelogs/67.txt new file mode 100644 index 000000000..3ee9dcc19 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/67.txt @@ -0,0 +1,9 @@ +Tusky v9.0 + +- Si possono creare sondaggi da Tusky +- Ricerca migliorata +- Nuova opzione nelle preferenze utente per espandere sempre i contenuti sensibili +- Le icone di navigazione hanno ora una forma quadrata arrotondata +- È ora possibile segnalare gli utenti anche prima che pubblichino nulla +- Tusky ora si rifiuterà di connettersi attraverso connessioni non cifrate su Android 6+ +- Molti altri piccoli miglioramenti e correzioni di errori diff --git a/fastlane/metadata/android/it/changelogs/68.txt b/fastlane/metadata/android/it/changelogs/68.txt new file mode 100644 index 000000000..645fb39c4 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/68.txt @@ -0,0 +1,3 @@ +Tusky v9.1 + +Questa versione è compatibile con Mastodon 3 e migliora prestazioni e stabilità. diff --git a/fastlane/metadata/android/it/full_description.txt b/fastlane/metadata/android/it/full_description.txt new file mode 100644 index 000000000..a123d9538 --- /dev/null +++ b/fastlane/metadata/android/it/full_description.txt @@ -0,0 +1,12 @@ +Tusky è un client leggero per Mastodon, un server gratuito e open-source per social network. + +• "Material Design" +• Implementa la maggior parte delle API di Mastodon +• Supporta più utenti simultanei +• Temi scuro e chiaro con possibilità di transizione automatica in base all'ora del giorno +• Bozze - per comporre i toots e conservarli per dopo +• Scelta tra diversi stili di emoji +• Ottimizzato per tutte le dimensioni di schermo +• Completamente open-source - nessuna dipendenza non libera es. servizi Google + +Per saperne di più su Mastodon, visita https://joinmastodon.org/ diff --git a/fastlane/metadata/android/it/short_description.txt b/fastlane/metadata/android/it/short_description.txt new file mode 100644 index 000000000..06089da00 --- /dev/null +++ b/fastlane/metadata/android/it/short_description.txt @@ -0,0 +1 @@ +Un client multi-utente per il social network Mastodon diff --git a/fastlane/metadata/android/it/title.txt b/fastlane/metadata/android/it/title.txt new file mode 100644 index 000000000..0238ffc0a --- /dev/null +++ b/fastlane/metadata/android/it/title.txt @@ -0,0 +1 @@ +Tusky From f56d5b10249f102fcc6fc35a8df553bb5ca9af46 Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 14 Dec 2019 23:39:29 +0000 Subject: [PATCH 13/29] Translated using Weblate (Turkish) Currently translated at 85.7% (6 of 7 strings) Translation: Tusky/Tusky-app Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/tr/ --- fastlane/metadata/android/tr/changelogs/61.txt | 7 +++++++ fastlane/metadata/android/tr/changelogs/67.txt | 9 +++++++++ fastlane/metadata/android/tr/changelogs/68.txt | 3 +++ fastlane/metadata/android/tr/full_description.txt | 12 ++++++++++++ fastlane/metadata/android/tr/short_description.txt | 1 + fastlane/metadata/android/tr/title.txt | 1 + 6 files changed, 33 insertions(+) create mode 100644 fastlane/metadata/android/tr/changelogs/61.txt create mode 100644 fastlane/metadata/android/tr/changelogs/67.txt create mode 100644 fastlane/metadata/android/tr/changelogs/68.txt create mode 100644 fastlane/metadata/android/tr/full_description.txt create mode 100644 fastlane/metadata/android/tr/short_description.txt create mode 100644 fastlane/metadata/android/tr/title.txt diff --git a/fastlane/metadata/android/tr/changelogs/61.txt b/fastlane/metadata/android/tr/changelogs/61.txt new file mode 100644 index 000000000..dc9c4fb04 --- /dev/null +++ b/fastlane/metadata/android/tr/changelogs/61.txt @@ -0,0 +1,7 @@ +Tusky v7.0 + +- Anket, oylama ve anket bildirimlerini görüntüleme desteği +- Bildirim sekmesini filtrelemek ve tüm bildirimleri silmek için yeni düğmeler +- Kendi iletilerini sil ve yeniden tasarla +- Bir hesabın profil resminde bot olup olmadığını gösteren yeni gösterge (tercihlerden kapatılabilir) +- Yeni çeviriler: Norveççe Bokmål ve Slovence. diff --git a/fastlane/metadata/android/tr/changelogs/67.txt b/fastlane/metadata/android/tr/changelogs/67.txt new file mode 100644 index 000000000..4816d6035 --- /dev/null +++ b/fastlane/metadata/android/tr/changelogs/67.txt @@ -0,0 +1,9 @@ +Tusky v9.0 + +- Artık Tusky'den Anket oluşturabilirsiniz +- Geliştirilmiş arama +- İçerik tercihlerini her zaman genişletmek için Hesap Tercihlerinde yeni seçenek +- Navigasyon çekmecesindeki avatarlar artık yuvarlatılmış kare şeklinde +- Artık kullanıcıları hiç durum bildirmemiş olsalar bile bildirmek mümkün. +- Tusky şimdi Android 6+ üzerindeki cleartext bağlantılarına bağlanmayı reddedecek +- Diğer bir sürü küçük iyileştirme ve hata düzeltmeleri diff --git a/fastlane/metadata/android/tr/changelogs/68.txt b/fastlane/metadata/android/tr/changelogs/68.txt new file mode 100644 index 000000000..05b1f9147 --- /dev/null +++ b/fastlane/metadata/android/tr/changelogs/68.txt @@ -0,0 +1,3 @@ +Tusky v9.1 + +Bu sürüm Mastodon 3 ile uyumluluğu sağlar, performansı ve kararlılığı arttırır. diff --git a/fastlane/metadata/android/tr/full_description.txt b/fastlane/metadata/android/tr/full_description.txt new file mode 100644 index 000000000..d741acbd6 --- /dev/null +++ b/fastlane/metadata/android/tr/full_description.txt @@ -0,0 +1,12 @@ +Tusky, ücretsiz ve açık kaynaklı bir sosyal ağ sunucusu olan Mastodon için hafif bir istemcidir. + +• Materyal Tasarım +• Çoğu Mastodon API'si uygulandı +• Çoklu Hesap desteği +• Gün içinde karanlık ve aydınlık tema arasında otomatik geçiş imkanı +• Taslak - ileti oluşturun ve daha sonra kullanmak üzere saklayın +• Farklı emoji stilleri arasında seçim yapma imkanı +• Tüm ekran boyutları için optimize edilmiş arayüz +• Tamamen açık kaynak - Google hizmet servisi gibi özgür olmayan bağımlılıklar yok + +Mastodon hakkında daha fazla bilgi edinmek için https://joinmastodon.org/ adresini ziyaret edin diff --git a/fastlane/metadata/android/tr/short_description.txt b/fastlane/metadata/android/tr/short_description.txt new file mode 100644 index 000000000..17a83c133 --- /dev/null +++ b/fastlane/metadata/android/tr/short_description.txt @@ -0,0 +1 @@ +Mastodon için çoklu hesap istemcisi diff --git a/fastlane/metadata/android/tr/title.txt b/fastlane/metadata/android/tr/title.txt new file mode 100644 index 000000000..0238ffc0a --- /dev/null +++ b/fastlane/metadata/android/tr/title.txt @@ -0,0 +1 @@ +Tusky From bac30031823bc9c56435f9482e73a8f66d5967d9 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Mon, 16 Dec 2019 19:51:21 +0100 Subject: [PATCH 14/29] Make polls respect content warning visibility setting (#1583) * Make polls respect content warning visibility setting * Only perform poll setup when poll is visible --- .../tusky/adapter/StatusBaseViewHolder.java | 82 +++++++++---------- .../conversation/ConversationViewHolder.java | 5 +- 2 files changed, 42 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 370864b58..5b74ca088 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -178,11 +178,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { @Nullable String spoilerText, @Nullable Status.Mention[] mentions, @NonNull List emojis, + @Nullable PollViewData poll, final StatusActionListener listener) { if (TextUtils.isEmpty(spoilerText)) { contentWarningDescription.setVisibility(View.GONE); contentWarningButton.setVisibility(View.GONE); - this.setTextVisible(true, content, mentions, emojis, listener); + this.setTextVisible(true, content, mentions, emojis, poll, listener); } else { CharSequence emojiSpoiler = CustomEmojiHelper.emojifyString(spoilerText, emojis, contentWarningDescription); contentWarningDescription.setText(emojiSpoiler); @@ -194,9 +195,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (getAdapterPosition() != RecyclerView.NO_POSITION) { listener.onExpandedChange(isChecked, getAdapterPosition()); } - this.setTextVisible(isChecked, content, mentions, emojis, listener); + this.setTextVisible(isChecked, content, mentions, emojis, poll, listener); }); - this.setTextVisible(expanded, content, mentions, emojis, listener); + this.setTextVisible(expanded, content, mentions, emojis, poll, listener); } } @@ -204,10 +205,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Spanned content, Status.Mention[] mentions, List emojis, + @Nullable PollViewData poll, final StatusActionListener listener) { if (expanded) { Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content); LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener); + if (poll != null) { + setupPoll(poll, emojis, listener); + } } else { LinkHelper.setClickableMentions(this.content, mentions, listener); } @@ -216,6 +221,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { this.content.setVisibility(View.VISIBLE); } + setPollVisible(poll != null && expanded); + } + + private void setPollVisible(boolean visible) { + int visibility = visible ? View.VISIBLE : View.GONE; + pollButton.setVisibility(visibility); + pollDescription.setVisibility(visibility); + pollOptions.setVisibility(visibility); } private void setAvatar(String url, @@ -674,12 +687,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setupButtons(listener, status.getSenderId()); setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); - setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener); + setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), status.getPoll(), listener); setDescriptionForStatus(status); - setupPoll(status.getPoll(), status.getStatusEmojis(), listener); - // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 // RecyclerView tries to set AccessibilityDelegateCompat to null // but ViewCompat code replaces is with the default one. RecyclerView never @@ -834,55 +845,44 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - protected void setupPoll(PollViewData poll, List emojis, StatusActionListener listener) { - if (poll == null) { + private void setupPoll(PollViewData poll, List emojis, StatusActionListener listener) { + long timestamp = System.currentTimeMillis(); - pollOptions.setVisibility(View.GONE); + boolean expired = poll.getExpired() || (poll.getExpiresAt() != null && timestamp > poll.getExpiresAt().getTime()); - pollDescription.setVisibility(View.GONE); + Context context = pollDescription.getContext(); + + pollOptions.setVisibility(View.VISIBLE); + + if (expired || poll.getVoted()) { + // no voting possible + pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, PollAdapter.RESULT); pollButton.setVisibility(View.GONE); - } else { - long timestamp = System.currentTimeMillis(); + // voting possible + pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE); - boolean expired = poll.getExpired() || (poll.getExpiresAt() != null && timestamp > poll.getExpiresAt().getTime()); + pollButton.setVisibility(View.VISIBLE); - Context context = pollDescription.getContext(); + pollButton.setOnClickListener(v -> { - pollOptions.setVisibility(View.VISIBLE); + int position = getAdapterPosition(); - if (expired || poll.getVoted()) { - // no voting possible - pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, PollAdapter.RESULT); + if (position != RecyclerView.NO_POSITION) { - pollButton.setVisibility(View.GONE); - } else { - // voting possible - pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE); + List pollResult = pollAdapter.getSelected(); - pollButton.setVisibility(View.VISIBLE); - - pollButton.setOnClickListener(v -> { - - int position = getAdapterPosition(); - - if (position != RecyclerView.NO_POSITION) { - - List pollResult = pollAdapter.getSelected(); - - if (!pollResult.isEmpty()) { - listener.onVoteInPoll(position, pollResult); - } + if (!pollResult.isEmpty()) { + listener.onVoteInPoll(position, pollResult); } + } - }); - } - - pollDescription.setVisibility(View.VISIBLE); - pollDescription.setText(getPollInfoText(timestamp, poll, context)); - + }); } + + pollDescription.setVisibility(View.VISIBLE); + pollDescription.setText(getPollInfoText(timestamp, poll, context)); } private CharSequence getPollInfoText(long timestamp, PollViewData poll, Context context) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index becb537f3..34651c201 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -108,14 +108,11 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setupButtons(listener, account.getId()); - setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), listener); + setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), PollViewDataKt.toViewData(status.getPoll()), listener); setConversationName(conversation.getAccounts()); setAvatars(conversation.getAccounts()); - - setupPoll(PollViewDataKt.toViewData(status.getPoll()), status.getEmojis(), listener); - } private void setConversationName(List accounts) { From 54b3d1f8cdff2422e01509796bf0845b2c077fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9lanie=20Chauvel=20=28ariasuni=29?= Date: Thu, 19 Dec 2019 18:35:55 +0100 Subject: [PATCH 15/29] Add close icon to tabs in Tabs account preferences page (#1588) * Add close icon to tabs in Tabs account preferences page * Improve code according to code review --- .../keylesspalace/tusky/ComposeActivity.java | 4 +-- .../tusky/TabPreferenceActivity.kt | 17 +++++++---- .../keylesspalace/tusky/adapter/TabAdapter.kt | 30 +++++++++++++++---- .../main/res/layout/item_tab_preference.xml | 22 ++++++++++++-- app/src/main/res/values-night/styles.xml | 2 +- app/src/main/res/values/attrs.xml | 2 +- app/src/main/res/values/colors.xml | 4 +-- app/src/main/res/values/styles.xml | 2 +- 8 files changed, 62 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index c1ab87a69..60b9324b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -1289,7 +1289,7 @@ public final class ComposeActivity private void enableButton(ImageButton button, boolean clickable, boolean colorActive) { button.setEnabled(clickable); ThemeUtils.setDrawableTint(this, button.getDrawable(), - colorActive ? android.R.attr.textColorTertiary : R.attr.compose_media_button_disabled_tint); + colorActive ? android.R.attr.textColorTertiary : R.attr.image_button_disabled_tint); } private void enablePollButton(boolean enable) { @@ -1298,7 +1298,7 @@ public final class ComposeActivity if(enable) { textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); } else { - textColor = ThemeUtils.getColor(this, R.attr.compose_media_button_disabled_tint); + textColor = ThemeUtils.getColor(this, R.attr.image_button_disabled_tint); } actionAddPoll.setTextColor(textColor); actionAddPoll.getCompoundDrawablesRelative()[0].setColorFilter(textColor, PorterDuff.Mode.SRC_IN); diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index fe8783acd..f7b38055b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -41,6 +41,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import kotlinx.android.synthetic.main.activity_tab_preference.* import kotlinx.android.synthetic.main.toolbar_basic.* +import kotlinx.android.synthetic.main.item_tab_preference.view.removeButton import java.util.regex.Pattern import javax.inject.Inject @@ -76,7 +77,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } currentTabs = (accountManager.activeAccount?.tabPreferences ?: emptyList()).toMutableList() - currentTabsAdapter = TabAdapter(currentTabs, false, this) + currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT) currentTabsRecyclerView.adapter = currentTabsAdapter currentTabsRecyclerView.layoutManager = LinearLayoutManager(this) currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) @@ -109,10 +110,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - currentTabs.removeAt(viewHolder.adapterPosition) - currentTabsAdapter.notifyItemRemoved(viewHolder.adapterPosition) - updateAvailableTabs() - saveTabs() + onTabRemoved(viewHolder.adapterPosition) } override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { @@ -168,6 +166,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene saveTabs() } + override fun onTabRemoved(position: Int) { + currentTabs.removeAt(position) + currentTabsAdapter.notifyItemRemoved(position) + updateAvailableTabs() + saveTabs() + } + override fun onActionChipClicked(tab: TabData) { showEditHashtagDialog(tab) } @@ -273,7 +278,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene addTabAdapter.updateData(addableTabs) maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT) - + currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT); } override fun onStartDelete(viewHolder: RecyclerView.ViewHolder) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt index a16898198..bc252ab98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt @@ -32,14 +32,16 @@ import kotlinx.android.synthetic.main.item_tab_preference.view.* interface ItemInteractionListener { fun onTabAdded(tab: TabData) + fun onTabRemoved(position: Int) fun onStartDelete(viewHolder: RecyclerView.ViewHolder) fun onStartDrag(viewHolder: RecyclerView.ViewHolder) fun onActionChipClicked(tab: TabData) } class TabAdapter(private var data: List, - private val small: Boolean = false, - private val listener: ItemInteractionListener? = null) : RecyclerView.Adapter() { + private val small: Boolean, + private val listener: ItemInteractionListener, + private var removeButtonEnabled: Boolean = false) : RecyclerView.Adapter() { fun updateData(newData: List) { this.data = newData @@ -67,17 +69,28 @@ class TabAdapter(private var data: List, holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null) if (small) { holder.itemView.textView.setOnClickListener { - listener?.onTabAdded(data[position]) + listener.onTabAdded(data[position]) } } holder.itemView.imageView?.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_DOWN) { - listener?.onStartDrag(holder) + listener.onStartDrag(holder) true } else { false } } + holder.itemView.removeButton?.setOnClickListener { + listener.onTabRemoved(holder.adapterPosition) + } + if (holder.itemView.removeButton != null) { + holder.itemView.removeButton.isEnabled = removeButtonEnabled + ThemeUtils.setDrawableTint( + holder.itemView.context, + holder.itemView.removeButton.drawable, + (if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.image_button_disabled_tint) + ) + } if (!small) { @@ -89,7 +102,7 @@ class TabAdapter(private var data: List, holder.itemView.actionChip.chipIcon = context.getDrawable(R.drawable.ic_edit_chip) holder.itemView.actionChip.setOnClickListener { - listener?.onActionChipClicked(data[position]) + listener.onActionChipClicked(data[position]) } } else { @@ -102,5 +115,12 @@ class TabAdapter(private var data: List, return data.size } + fun setRemoveButtonVisible(enabled: Boolean) { + if (removeButtonEnabled != enabled) { + removeButtonEnabled = enabled + notifyDataSetChanged() + } + } + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) } diff --git a/app/src/main/res/layout/item_tab_preference.xml b/app/src/main/res/layout/item_tab_preference.xml index e76c23999..deebdb907 100644 --- a/app/src/main/res/layout/item_tab_preference.xml +++ b/app/src/main/res/layout/item_tab_preference.xml @@ -7,7 +7,7 @@ android:background="?android:colorBackground" android:orientation="horizontal" android:paddingStart="16dp" - android:paddingTop="16dp" + android:paddingTop="8dp" android:paddingEnd="16dp"> @@ -24,6 +26,8 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" + android:paddingTop="8dp" + android:paddingBottom="8dp" android:layout_weight="1" android:drawablePadding="12dp" android:textColor="?android:attr/textColorSecondary" @@ -32,10 +36,23 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/imageView" app:layout_constraintTop_toTopOf="parent" - app:layout_goneMarginBottom="16dp" + app:layout_goneMarginBottom="8dp" tools:drawableStart="@drawable/ic_home_24dp" tools:text="Home" /> + + - diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 643a42066..90e692c49 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -47,7 +47,7 @@ @color/account_toolbar_icon_collapsed_dark @color/toolbar_icon_dark - @color/compose_media_button_disabled_dark + @color/image_button_disabled_dark @drawable/border_background_dark @color/compose_reply_content_background_dark diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 46395bb91..2be804853 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -30,7 +30,7 @@ - + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 92770f4cf..35a972cd2 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -26,7 +26,7 @@ #2f3441 #1a1c23 #ffffff - #586173 + #586173 #313543 #373c4b #424a5b @@ -54,7 +54,7 @@ #cfcfcf #cfcfcf #DE000000 - #a3a5ab + #a3a5ab #EFEFEF #ffffff #e0e1e6 diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 5eb6c78b0..8251dfd63 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -105,7 +105,7 @@ @color/toolbar_icon_light - @color/compose_media_button_disabled_light + @color/image_button_disabled_light @drawable/border_background_light From 65cc8e03d93075a9e1cd02a968675ff4335950fc Mon Sep 17 00:00:00 2001 From: Anonymous Date: Wed, 18 Dec 2019 13:39:37 +0000 Subject: [PATCH 16/29] Translated using Weblate (Russian) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ru/ --- app/src/main/res/values-ru/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b9483093c..d80a851ae 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -543,4 +543,6 @@ Нажмите для выбора времени отправки. Ошибка при поиске сообщения / ний - +Закладки + Добавить в закладки + From fcd9591e1bb6331ca52ddb203ad7b25637ecf344 Mon Sep 17 00:00:00 2001 From: tolstoevsky Date: Wed, 18 Dec 2019 13:39:37 +0000 Subject: [PATCH 17/29] Translated using Weblate (Russian) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ru/ --- app/src/main/res/values-ru/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d80a851ae..bb04ff055 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -545,4 +545,9 @@ Закладки Добавить в закладки + Закладки + Под управлением Tusky + Добавлено в закладки + Выбрать список + Список From 51e514461eaeac12ddf61d8c29fc511903525553 Mon Sep 17 00:00:00 2001 From: Juanjo Salvador Date: Wed, 18 Dec 2019 13:39:37 +0000 Subject: [PATCH 18/29] Translated using Weblate (Spanish) Currently translated at 100.0% (407 of 407 strings) Translation: Tusky/Tusky Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/ --- app/src/main/res/values-es/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 45c595d02..5d2e4887e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -473,4 +473,6 @@ Favorito Favoritos Marcado como favorito + Seleccionar lista + Lista From 8770fbe986fb1bd5344eccba75f8cb2ba711b3ca Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Thu, 19 Dec 2019 19:09:40 +0100 Subject: [PATCH 19/29] ComposeActivity refactor (#1541) * Convert ComposeActivity to Kotlin * More ComposeActivity cleanups * Move ComposeActivity to it's own package * Remove ComposeActivity.IntentBuilder * Re-do part of the media downsizing/uploading * Add sending of status to ViewModel, draft media descriptions * Allow uploading video, update description after uploading * Enable camera, enable upload cancelling * Cleanup of ComposeActivity * Extract CaptionDialog, extract ComposeActivity methods * Fix handling of redrafted media * Add initial state and media uploading out of Activity * Change ComposeOptions.mentionedUsernames to be Set rather than List We probably don't want repeated usernames when we are writing a post and Set provides such guarantee for free plus it tells it to the callers. The only disadvantage is lack of order but it shouldn't be a problem. * Add combineOptionalLiveData. Add docs. It it useful for nullable LiveData's. I think we cannot differentiate between value not being set and value being null so I just added the variant without null check. * Add poll support to Compose. * cleanup code * move more classes into compose package * cleanup code * fix button behavior * add error handling for media upload * add caching for instance data again * merge develop * fix scheduled toots * delete unused string * cleanup ComposeActivity * fix restoring media from drafts * make media upload code a little bit clearer * cleanup autocomplete search code * avoid duplicate object creation in SavedTootActivity * perf: avoid unnecessary work when initializing ComposeActivity * add license header to new files * use small toot button on bigger displays * fix ComposeActivityTest * fix bad merge * use Singles.zip instead of Single.zip --- app/build.gradle | 4 +- .../21.json | 729 ++++++ app/src/main/AndroidManifest.xml | 2 +- .../keylesspalace/tusky/AccountActivity.kt | 10 +- .../keylesspalace/tusky/ComposeActivity.java | 2276 ----------------- .../com/keylesspalace/tusky/MainActivity.java | 1 + .../tusky/SavedTootActivity.java | 77 +- .../tusky/ScheduledTootActivity.kt | 19 +- .../keylesspalace/tusky/TuskyApplication.java | 2 +- .../components/compose/ComposeActivity.kt | 994 +++++++ .../components/compose/ComposeViewModel.kt | 467 ++++ .../compose}/DownsizeImageTask.java | 48 +- .../components/compose/MediaPreviewAdapter.kt | 105 + .../tusky/components/compose/MediaUploader.kt | 203 ++ .../compose/dialog}/AddPollDialog.kt | 46 +- .../compose/dialog/CaptionDialog.kt | 113 + .../compose}/view/ComposeOptionsView.kt | 2 +- .../compose}/view/ComposeScheduleView.java | 13 +- .../compose}/view/EditTextTyped.kt | 2 +- .../compose}/view/PollPreviewView.kt | 2 +- .../compose}/view/ProgressImageView.java | 2 +- .../compose}/view/TootButton.kt | 2 +- .../fragments/SearchStatusesFragment.kt | 45 +- .../keylesspalace/tusky/db/AppDatabase.java | 10 +- .../com/keylesspalace/tusky/db/InstanceDao.kt | 3 +- .../keylesspalace/tusky/db/InstanceEntity.kt | 3 +- .../tusky/di/ActivitiesModule.kt | 1 + .../keylesspalace/tusky/di/AppComponent.kt | 3 +- .../tusky/di/MediaUploaderModule.kt | 30 + .../keylesspalace/tusky/di/ServicesModule.kt | 13 + .../tusky/di/ViewModelFactory.kt | 10 +- .../tusky/fragment/SFragment.java | 74 +- .../tusky/fragment/TimePickerFragment.java | 2 +- .../tusky/network/MastodonApi.kt | 14 +- .../receiver/SendStatusBroadcastReceiver.kt | 53 +- .../tusky/service/SendTootService.kt | 86 +- .../tusky/service/ServiceClient.kt | 34 + .../tusky/service/TuskyTileService.kt | 1 - .../keylesspalace/tusky/util/LiveDataUtil.kt | 93 + .../tusky/util/SaveTootHelper.java | 42 +- .../tusky/util/ViewExtensions.kt | 9 + app/src/main/res/layout/activity_compose.xml | 50 +- .../main/res/layout/view_compose_schedule.xml | 15 +- app/src/main/res/values-ar/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-cs/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-hu/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-night/styles.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-ru/strings.xml | 1 - app/src/main/res/values-sl/strings.xml | 1 - app/src/main/res/values-sv/strings.xml | 1 - .../toot_button.xml | 0 app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - app/src/main/res/values/styles.xml | 1 - .../tusky/ComposeActivityTest.kt | 95 +- 68 files changed, 3162 insertions(+), 2666 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json delete mode 100644 app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt rename app/src/main/java/com/keylesspalace/tusky/{util => components/compose}/DownsizeImageTask.java (88%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt rename app/src/main/java/com/keylesspalace/tusky/{view => components/compose/dialog}/AddPollDialog.kt (69%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt rename app/src/main/java/com/keylesspalace/tusky/{ => components/compose}/view/ComposeOptionsView.kt (98%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/compose}/view/ComposeScheduleView.java (95%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/compose}/view/EditTextTyped.kt (98%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/compose}/view/PollPreviewView.kt (97%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/compose}/view/ProgressImageView.java (98%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/compose}/view/TootButton.kt (97%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt rename app/src/main/res/{values-sw360dp => values-sw380dp}/toot_button.xml (100%) diff --git a/app/build.gradle b/app/build.gradle index 5d68379c5..d4fff4f36 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -92,6 +92,7 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { } } +ext.lifecycleVersion = "2.1.0" ext.roomVersion = '2.2.1' ext.retrofitVersion = '2.6.0' ext.okhttpVersion = '4.2.2' @@ -114,7 +115,8 @@ dependencies { implementation "androidx.sharetarget:sharetarget:1.0.0-beta01" implementation "androidx.emoji:emoji:1.0.0" implementation "androidx.emoji:emoji-appcompat:1.0.0" - implementation "androidx.lifecycle:lifecycle-extensions:2.1.0" + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-reactivestreams:$lifecycleVersion" implementation "androidx.constraintlayout:constraintlayout:1.1.3" implementation "androidx.paging:paging-runtime-ktx:2.1.0" implementation "androidx.viewpager2:viewpager2:1.0.0-rc01" diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json new file mode 100644 index 000000000..7845dade1 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json @@ -0,0 +1,729 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "7570c84ffeb4f90521f91dc7ef3e7da1", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` 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)", + "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": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "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, `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": "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, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` 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.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "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, '7570c84ffeb4f90521f91dc7ef3e7da1')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 44ba5455c..028b7aa40 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,7 +96,7 @@ { - if(loadedAccount != null) { + if (loadedAccount != null) { startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount!!.username)) } return true diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java deleted file mode 100644 index 60b9324b6..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ /dev/null @@ -1,2276 +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; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.app.DatePickerDialog; -import android.app.ProgressDialog; -import android.app.TimePickerDialog; -import android.content.ContentResolver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.AssetFileDescriptor; -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.PorterDuff; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.os.Parcel; -import android.os.Parcelable; -import android.preference.PreferenceManager; -import android.provider.MediaStore; -import android.text.Editable; -import android.text.InputFilter; -import android.text.InputType; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.text.style.URLSpan; -import android.util.DisplayMetrics; -import android.util.Log; -import android.view.KeyEvent; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.webkit.MimeTypeMap; -import android.widget.Button; -import android.widget.DatePicker; -import android.widget.EditText; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.PopupMenu; -import android.widget.TextView; -import android.widget.TimePicker; -import android.widget.Toast; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.Px; -import androidx.annotation.StringRes; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.widget.Toolbar; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.core.view.inputmethod.InputConnectionCompat; -import androidx.core.view.inputmethod.InputContentInfoCompat; -import androidx.lifecycle.Lifecycle; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.transition.TransitionManager; - -import com.bumptech.glide.Glide; -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.snackbar.Snackbar; -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter; -import com.keylesspalace.tusky.adapter.EmojiAdapter; -import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AppDatabase; -import com.keylesspalace.tusky.db.InstanceEntity; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Account; -import com.keylesspalace.tusky.entity.Attachment; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Instance; -import com.keylesspalace.tusky.entity.NewPoll; -import com.keylesspalace.tusky.entity.SearchResult; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.network.MastodonApi; -import com.keylesspalace.tusky.network.ProgressRequestBody; -import com.keylesspalace.tusky.service.SendTootService; -import com.keylesspalace.tusky.util.ComposeTokenizer; -import com.keylesspalace.tusky.util.CountUpDownLatch; -import com.keylesspalace.tusky.util.DownsizeImageTask; -import com.keylesspalace.tusky.util.IOUtils; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.SaveTootHelper; -import com.keylesspalace.tusky.util.SpanUtilsKt; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.util.ThemeUtils; -import com.keylesspalace.tusky.util.VersionUtils; -import com.keylesspalace.tusky.view.AddPollDialog; -import com.keylesspalace.tusky.view.ComposeOptionsListener; -import com.keylesspalace.tusky.view.ComposeOptionsView; -import com.keylesspalace.tusky.view.ComposeScheduleView; -import com.keylesspalace.tusky.view.EditTextTyped; -import com.keylesspalace.tusky.view.PollPreviewView; -import com.keylesspalace.tusky.view.ProgressImageView; -import com.keylesspalace.tusky.view.TootButton; -import com.mikepenz.google_material_typeface_library.GoogleMaterial; -import com.mikepenz.iconics.IconicsDrawable; - -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.CountDownLatch; - -import javax.inject.Inject; - -import at.connyduck.sparkbutton.helpers.Utils; -import io.reactivex.Single; -import io.reactivex.SingleObserver; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; -import kotlin.collections.CollectionsKt; -import okhttp3.MediaType; -import okhttp3.MultipartBody; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - -import static com.keylesspalace.tusky.util.MediaUtilsKt.MEDIA_SIZE_UNKNOWN; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageSquarePixels; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageThumbnail; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getMediaSize; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getSampledBitmap; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getVideoThumbnail; -import static com.uber.autodispose.AutoDispose.autoDisposable; -import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; - -public final class ComposeActivity - extends BaseActivity - implements ComposeOptionsListener, - ComposeAutoCompleteAdapter.AutocompletionProvider, - OnEmojiSelectedListener, - Injectable, InputConnectionCompat.OnCommitContentListener, - TimePickerDialog.OnTimeSetListener { - - private static final String TAG = "ComposeActivity"; // logging tag - static final int STATUS_CHARACTER_LIMIT = 500; - private static final int STATUS_IMAGE_SIZE_LIMIT = 8388608; // 8MiB - private static final int STATUS_VIDEO_SIZE_LIMIT = 41943040; // 40MiB - private static final int STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216; // 4096^2 Pixels - private static final int MEDIA_PICK_RESULT = 1; - private static final int MEDIA_TAKE_PHOTO_RESULT = 2; - private static final int PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1; - - private static final String SAVED_TOOT_UID_EXTRA = "saved_toot_uid"; - private static final String TOOT_TEXT_EXTRA = "toot_text"; - private static final String SAVED_JSON_URLS_EXTRA = "saved_json_urls"; - private static final String SAVED_JSON_DESCRIPTIONS_EXTRA = "saved_json_descriptions"; - private static final String TOOT_VISIBILITY_EXTRA = "toot_visibility"; - private static final String IN_REPLY_TO_ID_EXTRA = "in_reply_to_id"; - private static final String REPLY_VISIBILITY_EXTRA = "reply_visibility"; - private static final String CONTENT_WARNING_EXTRA = "content_warning"; - private static final String MENTIONED_USERNAMES_EXTRA = "mentioned_usernames"; - private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra"; - private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content"; - private static final String MEDIA_ATTACHMENTS_EXTRA = "media_attachments"; - private static final String SCHEDULED_AT_EXTRA = "scheduled_at"; - private static final String SENSITIVE_EXTRA = "sensitive"; - private static final String POLL_EXTRA = "poll"; - // Mastodon only counts URLs as this long in terms of status character limits - static final int MAXIMUM_URL_LENGTH = 23; - // https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94 - private static final int MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420; - - @Inject - public MastodonApi mastodonApi; - @Inject - public AppDatabase database; - - private TextView replyTextView; - private TextView replyContentTextView; - private EditTextTyped textEditor; - private LinearLayout mediaPreviewBar; - private View contentWarningBar; - private EditText contentWarningEditor; - private TextView charactersLeft; - private TootButton tootButton; - private ImageButton pickButton; - private ImageButton visibilityButton; - private ImageButton contentWarningButton; - private ImageButton emojiButton; - private ImageButton hideMediaToggle; - private ImageButton scheduleButton; - private TextView actionAddPoll; - private Button atButton; - private Button hashButton; - - private ComposeOptionsView composeOptionsView; - private BottomSheetBehavior composeOptionsBehavior; - private BottomSheetBehavior addMediaBehavior; - private BottomSheetBehavior emojiBehavior; - private BottomSheetBehavior scheduleBehavior; - private ComposeScheduleView scheduleView; - private RecyclerView emojiView; - - private PollPreviewView pollPreview; - - // this only exists when a status is trying to be sent, but uploads are still occurring - private ProgressDialog finishingUploadDialog; - private String inReplyToId; - private List mediaQueued = new ArrayList<>(); - private CountUpDownLatch waitForMediaLatch; - private NewPoll poll; - private Status.Visibility statusVisibility; // The current values of the options that will be applied - private boolean statusMarkSensitive; // to the status being composed. - private boolean statusHideText; - private String startingText = ""; - private String startingContentWarning = ""; - private InputContentInfoCompat currentInputContentInfo; - private int currentFlags; - private Uri photoUploadUri; - private int savedTootUid = 0; - private List emojiList; - private CountDownLatch emojiListRetrievalLatch = new CountDownLatch(1); - private int maximumTootCharacters = STATUS_CHARACTER_LIMIT; - private Integer maxPollOptions = null; - private Integer maxPollOptionLength = null; - private @Px - int thumbnailViewSize; - - private SaveTootHelper saveTootHelper; - private Gson gson = new Gson(); - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT); - if (theme.equals("black")) { - setTheme(R.style.TuskyDialogActivityBlackTheme); - } - setContentView(R.layout.activity_compose); - - replyTextView = findViewById(R.id.composeReplyView); - replyContentTextView = findViewById(R.id.composeReplyContentView); - textEditor = findViewById(R.id.composeEditField); - mediaPreviewBar = findViewById(R.id.compose_media_preview_bar); - contentWarningBar = findViewById(R.id.composeContentWarningBar); - contentWarningEditor = findViewById(R.id.composeContentWarningField); - charactersLeft = findViewById(R.id.composeCharactersLeftView); - tootButton = findViewById(R.id.composeTootButton); - pickButton = findViewById(R.id.composeAddMediaButton); - visibilityButton = findViewById(R.id.composeToggleVisibilityButton); - contentWarningButton = findViewById(R.id.composeContentWarningButton); - emojiButton = findViewById(R.id.composeEmojiButton); - hideMediaToggle = findViewById(R.id.composeHideMediaButton); - scheduleButton = findViewById(R.id.composeScheduleButton); - scheduleView = findViewById(R.id.composeScheduleView); - emojiView = findViewById(R.id.emojiView); - emojiList = Collections.emptyList(); - atButton = findViewById(R.id.atButton); - hashButton = findViewById(R.id.hashButton); - - saveTootHelper = new SaveTootHelper(database.tootDao(), this); - - // Setup the toolbar. - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setTitle(null); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowHomeEnabled(true); - Drawable closeIcon = AppCompatResources.getDrawable(this, R.drawable.ic_close_24dp); - ThemeUtils.setDrawableTint(this, closeIcon, R.attr.compose_close_button_tint); - actionBar.setHomeAsUpIndicator(closeIcon); - } - - // setup the account image - final AccountEntity activeAccount = accountManager.getActiveAccount(); - - if (activeAccount != null) { - ImageView composeAvatar = findViewById(R.id.composeAvatar); - - - int[] actionBarSizeAttr = new int[] { R.attr.actionBarSize }; - TypedArray a = obtainStyledAttributes(null, actionBarSizeAttr); - int avatarSize = a.getDimensionPixelSize(0, 1); - a.recycle(); - - boolean animateAvatars = preferences.getBoolean("animateGifAvatars", false); - - ImageLoadingHelper.loadAvatar( - activeAccount.getProfilePictureUrl(), - composeAvatar, - avatarSize / 8, - animateAvatars - ); - - composeAvatar.setContentDescription( - getString(R.string.compose_active_account_description, - activeAccount.getFullName())); - - mastodonApi.getInstance() - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(this::onFetchInstanceSuccess, this::onFetchInstanceFailure); - - mastodonApi.getCustomEmojis().enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, @NonNull Response> response) { - List emojiList = response.body(); - if (emojiList == null) { - emojiList = Collections.emptyList(); - } - Collections.sort(emojiList, (a, b) -> - a.getShortcode().toLowerCase(Locale.ROOT).compareTo( - b.getShortcode().toLowerCase(Locale.ROOT))); - setEmojiList(emojiList); - cacheInstanceMetadata(activeAccount); - } - - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - Log.w(TAG, "error loading custom emojis", t); - loadCachedInstanceMetadata(activeAccount); - } - }); - } else { - // do not do anything when not logged in, activity will be finished in super.onCreate() anyway - return; - } - - composeOptionsView = findViewById(R.id.composeOptionsBottomSheet); - composeOptionsView.setListener(this); - - composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsView); - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - - addMediaBehavior = BottomSheetBehavior.from(findViewById(R.id.addMediaBottomSheet)); - - scheduleBehavior = BottomSheetBehavior.from(scheduleView); - - emojiBehavior = BottomSheetBehavior.from(emojiView); - - emojiView.setLayoutManager(new GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false)); - - enableButton(emojiButton, false, false); - - // Setup the interface buttons. - tootButton.setOnClickListener(v -> onSendClicked()); - pickButton.setOnClickListener(v -> openPickDialog()); - visibilityButton.setOnClickListener(v -> showComposeOptions()); - contentWarningButton.setOnClickListener(v -> onContentWarningChanged()); - emojiButton.setOnClickListener(v -> showEmojis()); - hideMediaToggle.setOnClickListener(v -> toggleHideMedia()); - scheduleButton.setOnClickListener(v -> showScheduleView()); - scheduleView.setResetOnClickListener(v -> resetSchedule()); - atButton.setOnClickListener(v -> atButtonClicked()); - hashButton.setOnClickListener(v -> hashButtonClicked()); - - TextView actionPhotoTake = findViewById(R.id.action_photo_take); - TextView actionPhotoPick = findViewById(R.id.action_photo_pick); - actionAddPoll = findViewById(R.id.action_add_poll); - - int textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - - Drawable cameraIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).color(textColor).sizeDp(18); - actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null); - - Drawable imageIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18); - actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null); - - Drawable pollIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).color(textColor).sizeDp(18); - actionAddPoll.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null); - - actionPhotoTake.setOnClickListener(v -> initiateCameraApp()); - actionPhotoPick.setOnClickListener(v -> onMediaPick()); - actionAddPoll.setOnClickListener(v -> openPollDialog()); - - thumbnailViewSize = getResources().getDimensionPixelSize(R.dimen.compose_media_preview_size); - - /* Initialise all the state, or restore it from a previous run, to determine a "starting" - * state. */ - Status.Visibility startingVisibility = Status.Visibility.UNKNOWN; - boolean startingHideText; - ArrayList savedMediaQueued = null; - if (savedInstanceState != null) { - startingVisibility = Status.Visibility.byNum( - savedInstanceState.getInt("statusVisibility", - Status.Visibility.PUBLIC.getNum()) - ); - statusMarkSensitive = savedInstanceState.getBoolean("statusMarkSensitive"); - startingHideText = savedInstanceState.getBoolean("statusHideText"); - // Keep these until everything needed to put them in the queue is finished initializing. - savedMediaQueued = savedInstanceState.getParcelableArrayList("savedMediaQueued"); - // These are for restoring an in-progress commit content operation. - InputContentInfoCompat previousInputContentInfo = InputContentInfoCompat.wrap( - savedInstanceState.getParcelable("commitContentInputContentInfo")); - int previousFlags = savedInstanceState.getInt("commitContentFlags"); - if (previousInputContentInfo != null) { - onCommitContentInternal(previousInputContentInfo, previousFlags); - } - photoUploadUri = savedInstanceState.getParcelable("photoUploadUri"); - } else { - statusMarkSensitive = activeAccount.getDefaultMediaSensitivity(); - startingHideText = false; - photoUploadUri = null; - } - - /* If the composer is started up as a reply to another post, override the "starting" state - * based on what the intent from the reply request passes. */ - Intent intent = getIntent(); - - String[] mentionedUsernames = null; - ArrayList loadedDraftMediaUris = null; - ArrayList loadedDraftMediaDescriptions = null; - ArrayList mediaAttachments = null; - inReplyToId = null; - if (intent != null) { - - if (startingVisibility == Status.Visibility.UNKNOWN) { - Status.Visibility preferredVisibility = activeAccount.getDefaultPostPrivacy(); - Status.Visibility replyVisibility = Status.Visibility.byNum( - intent.getIntExtra(REPLY_VISIBILITY_EXTRA, Status.Visibility.UNKNOWN.getNum())); - - startingVisibility = Status.Visibility.byNum(Math.max(preferredVisibility.getNum(), replyVisibility.getNum())); - } - - inReplyToId = intent.getStringExtra(IN_REPLY_TO_ID_EXTRA); - - mentionedUsernames = intent.getStringArrayExtra(MENTIONED_USERNAMES_EXTRA); - - String contentWarning = intent.getStringExtra(CONTENT_WARNING_EXTRA); - if (contentWarning != null) { - startingHideText = !contentWarning.isEmpty(); - if (startingHideText) { - startingContentWarning = contentWarning; - } - } - - String tootText = intent.getStringExtra(TOOT_TEXT_EXTRA); - if (!TextUtils.isEmpty(tootText)) { - textEditor.setText(tootText); - } - - // try to redo a list of media - // If come from SavedTootActivity - String savedJsonUrls = intent.getStringExtra(SAVED_JSON_URLS_EXTRA); - String savedJsonDescriptions = intent.getStringExtra(SAVED_JSON_DESCRIPTIONS_EXTRA); - if (!TextUtils.isEmpty(savedJsonUrls)) { - loadedDraftMediaUris = gson.fromJson(savedJsonUrls, - new TypeToken>() { - }.getType()); - } - if (!TextUtils.isEmpty(savedJsonDescriptions)) { - loadedDraftMediaDescriptions = gson.fromJson(savedJsonDescriptions, - new TypeToken>() { - }.getType()); - } - // If come from redraft - mediaAttachments = intent.getParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA); - - int savedTootUid = intent.getIntExtra(SAVED_TOOT_UID_EXTRA, 0); - if (savedTootUid != 0) { - this.savedTootUid = savedTootUid; - - // If come from SavedTootActivity - startingText = tootText; - } - - int tootVisibility = intent.getIntExtra(TOOT_VISIBILITY_EXTRA, Status.Visibility.UNKNOWN.getNum()); - if (tootVisibility != Status.Visibility.UNKNOWN.getNum()) { - startingVisibility = Status.Visibility.byNum(tootVisibility); - } - - if (intent.hasExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA)) { - replyTextView.setVisibility(View.VISIBLE); - String username = intent.getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA); - replyTextView.setText(getString(R.string.replying_to, username)); - Drawable arrowDownIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).sizeDp(12); - - ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary); - replyTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null); - - replyTextView.setOnClickListener(v -> { - TransitionManager.beginDelayedTransition((ViewGroup) replyContentTextView.getParent()); - - if (replyContentTextView.getVisibility() != View.VISIBLE) { - replyContentTextView.setVisibility(View.VISIBLE); - Drawable arrowUpIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).sizeDp(12); - - ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary); - replyTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null); - } else { - replyContentTextView.setVisibility(View.GONE); - replyTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null); - } - }); - } - - if (intent.hasExtra(REPLYING_STATUS_CONTENT_EXTRA)) { - replyContentTextView.setText(intent.getStringExtra(REPLYING_STATUS_CONTENT_EXTRA)); - } - - String scheduledAt = intent.getStringExtra(SCHEDULED_AT_EXTRA); - if (!TextUtils.isEmpty(scheduledAt)) { - scheduleView.setDateTime(scheduledAt); - } - - statusMarkSensitive = intent.getBooleanExtra(SENSITIVE_EXTRA, statusMarkSensitive); - - if(intent.hasExtra(POLL_EXTRA) && (mediaAttachments == null || mediaAttachments.size() == 0)) { - updatePoll(intent.getParcelableExtra(POLL_EXTRA)); - } - - if(mediaAttachments != null && mediaAttachments.size() > 0) { - enablePollButton(false); - } - } - - // After the starting state is finalised, the interface can be set to reflect this state. - setStatusVisibility(startingVisibility); - - updateHideMediaToggle(); - updateScheduleButton(); - updateVisibleCharactersLeft(); - - // Setup the main text field. - textEditor.setOnCommitContentListener(this); - final int mentionColour = textEditor.getLinkTextColors().getDefaultColor(); - SpanUtilsKt.highlightSpans(textEditor.getText(), mentionColour); - textEditor.addTextChangedListener(new TextWatcher() { - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void afterTextChanged(Editable editable) { - SpanUtilsKt.highlightSpans(editable, mentionColour); - updateVisibleCharactersLeft(); - } - }); - - textEditor.setOnKeyListener((view, keyCode, event) -> this.onKeyDown(keyCode, event)); - - textEditor.setAdapter( - new ComposeAutoCompleteAdapter(this)); - textEditor.setTokenizer(new ComposeTokenizer()); - - // Add any mentions to the text field when a reply is first composed. - if (mentionedUsernames != null) { - StringBuilder builder = new StringBuilder(); - for (String name : mentionedUsernames) { - builder.append('@'); - builder.append(name); - builder.append(' '); - } - startingText = builder.toString(); - textEditor.setText(startingText); - textEditor.setSelection(textEditor.length()); - } - - // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { - textEditor.setLayerType(View.LAYER_TYPE_SOFTWARE, null); - } - - // Initialise the content warning editor. - contentWarningEditor.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - updateVisibleCharactersLeft(); - } - - @Override - public void afterTextChanged(Editable s) { - } - }); - showContentWarning(startingHideText); - if (startingContentWarning != null) { - contentWarningEditor.setText(startingContentWarning); - } - - // Initialise the empty media queue state. - waitForMediaLatch = new CountUpDownLatch(); - - // These can only be added after everything affected by the media queue is initialized. - if (!ListUtils.isEmpty(loadedDraftMediaUris)) { - for (int mediaIndex = 0; mediaIndex < loadedDraftMediaUris.size(); ++mediaIndex) { - Uri uri = Uri.parse(loadedDraftMediaUris.get(mediaIndex)); - long mediaSize = getMediaSize(getContentResolver(), uri); - String description = null; - if (loadedDraftMediaDescriptions != null && mediaIndex < loadedDraftMediaDescriptions.size()) { - description = loadedDraftMediaDescriptions.get(mediaIndex); - } - pickMedia(uri, mediaSize, description); - } - } else if (!ListUtils.isEmpty(mediaAttachments)) { - for (int mediaIndex = 0; mediaIndex < mediaAttachments.size(); ++mediaIndex) { - Attachment media = mediaAttachments.get(mediaIndex); - QueuedMedia.Type type; - switch (media.getType()) { - case UNKNOWN: - case IMAGE: - default: { - type = QueuedMedia.Type.IMAGE; - break; - } - case VIDEO: - case GIFV: { - type = QueuedMedia.Type.VIDEO; - break; - } - } - addMediaToQueue(media.getId(), type, media.getPreviewUrl(), media.getDescription()); - } - } else if (savedMediaQueued != null) { - for (SavedQueuedMedia item : savedMediaQueued) { - Bitmap preview = getImageThumbnail(getContentResolver(), item.uri, thumbnailViewSize); - addMediaToQueue(item.id, item.type, preview, item.uri, item.mediaSize, item.readyStage, item.description); - } - } else if (intent != null && savedInstanceState == null) { - /* Get incoming images being sent through a share action from another app. Only do this - * when savedInstanceState is null, otherwise both the images from the intent and the - * instance state will be re-queued. */ - String type = intent.getType(); - if (type != null) { - if (type.startsWith("image/") || type.startsWith("video/")) { - List uriList = new ArrayList<>(); - if (intent.getAction() != null) { - switch (intent.getAction()) { - case Intent.ACTION_SEND: { - Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - uriList.add(uri); - } - break; - } - case Intent.ACTION_SEND_MULTIPLE: { - ArrayList list = intent.getParcelableArrayListExtra( - Intent.EXTRA_STREAM); - if (list != null) { - for (Uri uri : list) { - if (uri != null) { - uriList.add(uri); - } - } - } - break; - } - } - } - for (Uri uri : uriList) { - long mediaSize = getMediaSize(getContentResolver(), uri); - pickMedia(uri, mediaSize, null); - } - } else if (type.equals("text/plain")) { - String action = intent.getAction(); - if (action != null && action.equals(Intent.ACTION_SEND)) { - String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); - String text = intent.getStringExtra(Intent.EXTRA_TEXT); - String shareBody = null; - if (subject != null && text != null) { - if (!subject.equals(text) && !text.contains(subject)) { - shareBody = String.format("%s\n%s", subject, text); - } else { - shareBody = text; - } - } else if (text != null) { - shareBody = text; - } else if (subject != null) { - shareBody = subject; - } - - if (shareBody != null) { - int start = Math.max(textEditor.getSelectionStart(), 0); - int end = Math.max(textEditor.getSelectionEnd(), 0); - int left = Math.min(start, end); - int right = Math.max(start, end); - textEditor.getText().replace(left, right, shareBody, 0, shareBody.length()); - } - } - } - } - } - for (QueuedMedia item : mediaQueued) { - item.preview.setChecked(!TextUtils.isEmpty(item.description)); - } - - textEditor.requestFocus(); - } - - private void replaceTextAtCaret(CharSequence text) { - // If you select "backward" in an editable, you get SelectionStart > SelectionEnd - int start = Math.min(textEditor.getSelectionStart(), textEditor.getSelectionEnd()); - int end = Math.max(textEditor.getSelectionStart(), textEditor.getSelectionEnd()); - textEditor.getText().replace(start, end, text); - - // Set the cursor after the inserted text - textEditor.setSelection(start + text.length()); - } - - private void atButtonClicked() { - replaceTextAtCaret("@"); - } - - private void hashButtonClicked() { - replaceTextAtCaret("#"); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - ArrayList savedMediaQueued = new ArrayList<>(); - for (QueuedMedia item : mediaQueued) { - savedMediaQueued.add(new SavedQueuedMedia(item.id, item.type, item.uri, - item.mediaSize, item.readyStage, item.description)); - } - outState.putParcelableArrayList("savedMediaQueued", savedMediaQueued); - outState.putBoolean("statusMarkSensitive", statusMarkSensitive); - outState.putBoolean("statusHideText", statusHideText); - if (currentInputContentInfo != null) { - outState.putParcelable("commitContentInputContentInfo", - (Parcelable) currentInputContentInfo.unwrap()); - outState.putInt("commitContentFlags", currentFlags); - } - currentInputContentInfo = null; - currentFlags = 0; - outState.putParcelable("photoUploadUri", photoUploadUri); - outState.putInt("statusVisibility", statusVisibility.getNum()); - super.onSaveInstanceState(outState); - } - - private void doErrorDialog(@StringRes int descriptionId, @StringRes int actionId, - View.OnClickListener listener) { - Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), getString(descriptionId), - Snackbar.LENGTH_SHORT); - bar.setAction(actionId, listener); - //necessary so snackbar is shown over everything - bar.getView().setElevation(getResources().getDimensionPixelSize(R.dimen.compose_activity_snackbar_elevation)); - bar.show(); - } - - private void displayTransientError(@StringRes int stringId) { - Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), stringId, Snackbar.LENGTH_LONG); - //necessary so snackbar is shown over everything - bar.getView().setElevation(getResources().getDimensionPixelSize(R.dimen.compose_activity_snackbar_elevation)); - bar.show(); - } - - private void toggleHideMedia() { - statusMarkSensitive = !statusMarkSensitive; - updateHideMediaToggle(); - } - - private void updateHideMediaToggle() { - TransitionManager.beginDelayedTransition((ViewGroup) hideMediaToggle.getParent()); - - @ColorInt int color; - if (mediaQueued.size() == 0) { - hideMediaToggle.setVisibility(View.GONE); - } else { - hideMediaToggle.setVisibility(View.VISIBLE); - if (statusMarkSensitive) { - hideMediaToggle.setImageResource(R.drawable.ic_hide_media_24dp); - if (statusHideText) { - hideMediaToggle.setClickable(false); - color = ContextCompat.getColor(this, R.color.compose_media_visible_button_disabled_blue); - } else { - hideMediaToggle.setClickable(true); - color = ContextCompat.getColor(this, R.color.tusky_blue); - } - } else { - hideMediaToggle.setClickable(true); - hideMediaToggle.setImageResource(R.drawable.ic_eye_24dp); - color = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - } - hideMediaToggle.getDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN); - } - } - - private void updateScheduleButton() { - @ColorInt int color; - if(scheduleView.getTime() == null) { - color = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - } else { - color = ContextCompat.getColor(this, R.color.tusky_blue); - } - scheduleButton.getDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN); - } - - private void disableButtons() { - pickButton.setClickable(false); - visibilityButton.setClickable(false); - emojiButton.setClickable(false); - hideMediaToggle.setClickable(false); - scheduleButton.setClickable(false); - tootButton.setEnabled(false); - } - - private void enableButtons() { - pickButton.setClickable(true); - visibilityButton.setClickable(true); - emojiButton.setClickable(true); - hideMediaToggle.setClickable(true); - scheduleButton.setClickable(true); - tootButton.setEnabled(true); - } - - private void setStatusVisibility(Status.Visibility visibility) { - statusVisibility = visibility; - composeOptionsView.setStatusVisibility(visibility); - tootButton.setStatusVisibility(visibility); - - switch (visibility) { - case PUBLIC: { - Drawable globe = AppCompatResources.getDrawable(this, R.drawable.ic_public_24dp); - if (globe != null) { - visibilityButton.setImageDrawable(globe); - } - break; - } - case PRIVATE: { - Drawable lock = AppCompatResources.getDrawable(this, - R.drawable.ic_lock_outline_24dp); - if (lock != null) { - visibilityButton.setImageDrawable(lock); - } - break; - } - case DIRECT: { - Drawable envelope = AppCompatResources.getDrawable(this, R.drawable.ic_email_24dp); - if (envelope != null) { - visibilityButton.setImageDrawable(envelope); - } - break; - } - case UNLISTED: - default: { - Drawable openLock = AppCompatResources.getDrawable(this, R.drawable.ic_lock_open_24dp); - if (openLock != null) { - visibilityButton.setImageDrawable(openLock); - } - break; - } - } - } - - private void showComposeOptions() { - if (composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else { - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - } - - private void showScheduleView() { - if (scheduleBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { - scheduleBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else { - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - } - - private void showEmojis() { - - if (emojiView.getAdapter() != null) { - if (emojiView.getAdapter().getItemCount() == 0) { - String errorMessage = getString(R.string.error_no_custom_emojis, accountManager.getActiveAccount().getDomain()); - Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show(); - } else { - if (emojiBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { - emojiBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else { - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - } - - } - - } - - private void openPickDialog() { - if (addMediaBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { - addMediaBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else { - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - - } - - private void onMediaPick() { - addMediaBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull View bottomSheet, int newState) { - //Wait until bottom sheet is not collapsed and show next screen after - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - addMediaBehavior.setBottomSheetCallback(null); - if (ContextCompat.checkSelfPermission(ComposeActivity.this, Manifest.permission.READ_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(ComposeActivity.this, - new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); - } else { - initiateMediaPicking(); - } - } - } - - @Override - public void onSlide(@NonNull View bottomSheet, float slideOffset) { - - } - }); - addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - } - - private void openPollDialog() { - addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - AddPollDialog.showAddPollDialog(this, poll, maxPollOptions, maxPollOptionLength); - } - - public void updatePoll(NewPoll poll) { - this.poll = poll; - - enableButton(pickButton, false, false); - - if(pollPreview == null) { - - pollPreview = new PollPreviewView(this); - - Resources resources = getResources(); - int margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin); - int marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom); - - LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - layoutParams.setMargins(margin, margin, margin, marginBottom); - pollPreview.setLayoutParams(layoutParams); - - mediaPreviewBar.addView(pollPreview); - - pollPreview.setOnClickListener(v -> { - PopupMenu popup = new PopupMenu(this, pollPreview); - final int editId = 1; - final int removeId = 2; - popup.getMenu().add(0, editId, 0, R.string.edit_poll); - popup.getMenu().add(0, removeId, 0, R.string.action_remove); - popup.setOnMenuItemClickListener(menuItem -> { - switch (menuItem.getItemId()) { - case editId: - openPollDialog(); - break; - case removeId: - removePoll(); - break; - } - return true; - }); - popup.show(); - }); - } - - pollPreview.setPoll(poll); - - } - - private void removePoll() { - poll = null; - pollPreview = null; - enableButton(pickButton, true, true); - mediaPreviewBar.removeAllViews(); - } - - @Override - public void onVisibilityChanged(@NonNull Status.Visibility visibility) { - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - setStatusVisibility(visibility); - } - - int calculateTextLength() { - int offset = 0; - URLSpan[] urlSpans = textEditor.getUrls(); - if (urlSpans != null) { - for (URLSpan span : urlSpans) { - offset += Math.max(0, span.getURL().length() - MAXIMUM_URL_LENGTH); - } - } - int length = textEditor.length() - offset; - if (statusHideText) { - length += contentWarningEditor.length(); - } - return length; - } - - private void updateVisibleCharactersLeft() { - this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", maximumTootCharacters - calculateTextLength())); - } - - private void onContentWarningChanged() { - boolean showWarning = contentWarningBar.getVisibility() != View.VISIBLE; - showContentWarning(showWarning); - updateVisibleCharactersLeft(); - } - - private void onSendClicked() { - disableButtons(); - readyStatus(statusVisibility, statusMarkSensitive); - } - - @Override - public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { - try { - if (currentInputContentInfo != null) { - currentInputContentInfo.releasePermission(); - } - } catch (Exception e) { - Log.e(TAG, "InputContentInfoCompat#releasePermission() failed." + e.getMessage()); - } finally { - currentInputContentInfo = null; - } - - // Verify the returned content's type is of the correct MIME type - boolean supported = inputContentInfo.getDescription().hasMimeType("image/*"); - - return supported && onCommitContentInternal(inputContentInfo, flags); - } - - private boolean onCommitContentInternal(InputContentInfoCompat inputContentInfo, int flags) { - if ((flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { - try { - inputContentInfo.requestPermission(); - } catch (Exception e) { - Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.getMessage()); - return false; - } - } - - // Determine the file size before putting handing it off to be put in the queue. - Uri uri = inputContentInfo.getContentUri(); - long mediaSize; - AssetFileDescriptor descriptor = null; - try { - descriptor = getContentResolver().openAssetFileDescriptor(uri, "r"); - } catch (FileNotFoundException e) { - Log.d(TAG, Log.getStackTraceString(e)); - // Eat this exception, having the descriptor be null is sufficient. - } - if (descriptor != null) { - mediaSize = descriptor.getLength(); - try { - descriptor.close(); - } catch (IOException e) { - // Just eat this exception. - } - } else { - mediaSize = MEDIA_SIZE_UNKNOWN; - } - pickMedia(uri, mediaSize, null); - - currentInputContentInfo = inputContentInfo; - currentFlags = flags; - - return true; - } - - private void sendStatus(String content, Status.Visibility visibility, boolean sensitive, - String spoilerText) { - ArrayList mediaIds = new ArrayList<>(); - ArrayList mediaUris = new ArrayList<>(); - ArrayList mediaDescriptions = new ArrayList<>(); - for (QueuedMedia item : mediaQueued) { - mediaIds.add(item.id); - mediaUris.add(item.uri); - mediaDescriptions.add(item.description); - } - - Intent sendIntent = SendTootService.sendTootIntent(this, content, spoilerText, - visibility, !mediaUris.isEmpty() && sensitive, mediaIds, mediaUris, mediaDescriptions, - scheduleView.getTime(), inReplyToId, poll, - getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), - getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), - getIntent().getStringExtra(SAVED_JSON_URLS_EXTRA), - accountManager.getActiveAccount(), savedTootUid); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(sendIntent); - } else { - startService(sendIntent); - } - - finishWithoutSlideOutAnimation(); - - } - - private void readyStatus(final Status.Visibility visibility, final boolean sensitive) { - if (waitForMediaLatch.isEmpty()) { - onReadySuccess(visibility, sensitive); - return; - } - finishingUploadDialog = ProgressDialog.show( - this, getString(R.string.dialog_title_finishing_media_upload), - getString(R.string.dialog_message_uploading_media), true, true); - @SuppressLint("StaticFieldLeak") final AsyncTask waitForMediaTask = - new AsyncTask() { - @Override - protected Boolean doInBackground(Void... params) { - try { - waitForMediaLatch.await(); - } catch (InterruptedException e) { - return false; - } - return true; - } - - @Override - protected void onPostExecute(Boolean successful) { - super.onPostExecute(successful); - finishingUploadDialog.dismiss(); - finishingUploadDialog = null; - if (successful) { - onReadySuccess(visibility, sensitive); - } else { - onReadyFailure(visibility, sensitive); - } - } - - @Override - protected void onCancelled() { - removeAllMediaFromQueue(); - enableButtons(); - super.onCancelled(); - } - }; - finishingUploadDialog.setOnCancelListener(dialog -> { - /* Generating an interrupt by passing true here is important because an interrupt - * exception is the only thing that will kick the latch out of its waiting loop - * early. */ - waitForMediaTask.cancel(true); - }); - waitForMediaTask.execute(); - } - - private void onReadySuccess(Status.Visibility visibility, boolean sensitive) { - /* Validate the status meets the character limit. */ - String contentText = textEditor.getText().toString(); - String spoilerText = ""; - if (statusHideText) { - spoilerText = contentWarningEditor.getText().toString(); - } - int characterCount = calculateTextLength(); - if ((characterCount <= 0 || contentText.trim().length() <= 0) && mediaQueued.size() == 0) { - textEditor.setError(getString(R.string.error_empty)); - enableButtons(); - } else if (characterCount <= maximumTootCharacters) { - sendStatus(contentText, visibility, sensitive, spoilerText); - - } else { - textEditor.setError(getString(R.string.error_compose_character_limit)); - enableButtons(); - } - } - - private void onReadyFailure(final Status.Visibility visibility, final boolean sensitive) { - doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, - v -> readyStatus(visibility, sensitive)); - enableButtons(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], - @NonNull int[] grantResults) { - switch (requestCode) { - case PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: { - if (grantResults.length > 0 - && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - initiateMediaPicking(); - } else { - doErrorDialog(R.string.error_media_upload_permission, R.string.action_retry, - v -> onMediaPick()); - } - break; - } - } - } - - @NonNull - private File createNewImageFile() throws IOException { - // Create an image file name - String randomId = StringUtils.randomAlphanumericString(12); - String imageFileName = "Tusky_" + randomId + "_"; - File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES); - return File.createTempFile( - imageFileName, /* prefix */ - ".jpg", /* suffix */ - storageDir /* directory */ - ); - } - - private void initiateCameraApp() { - addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - - // We don't need to ask for permission in this case, because the used calls require - // android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was - // way before permission dialogues have been introduced. - Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - if (intent.resolveActivity(getPackageManager()) != null) { - File photoFile = null; - try { - photoFile = createNewImageFile(); - } catch (IOException ex) { - displayTransientError(R.string.error_media_upload_opening); - } - // Continue only if the File was successfully created - if (photoFile != null) { - photoUploadUri = FileProvider.getUriForFile(this, - BuildConfig.APPLICATION_ID + ".fileprovider", - photoFile); - intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri); - startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT); - } - } - } - - private void initiateMediaPicking() { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - - String[] mimeTypes = new String[]{"image/*", "video/*"}; - intent.setType("*/*"); - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); - startActivityForResult(intent, MEDIA_PICK_RESULT); - } - - private void enableButton(ImageButton button, boolean clickable, boolean colorActive) { - button.setEnabled(clickable); - ThemeUtils.setDrawableTint(this, button.getDrawable(), - colorActive ? android.R.attr.textColorTertiary : R.attr.image_button_disabled_tint); - } - - private void enablePollButton(boolean enable) { - actionAddPoll.setEnabled(enable); - int textColor; - if(enable) { - textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - } else { - textColor = ThemeUtils.getColor(this, R.attr.image_button_disabled_tint); - } - actionAddPoll.setTextColor(textColor); - actionAddPoll.getCompoundDrawablesRelative()[0].setColorFilter(textColor, PorterDuff.Mode.SRC_IN); - } - - private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize, @Nullable String description) { - addMediaToQueue(null, type, preview, uri, mediaSize, null, description); - } - - private void addMediaToQueue(String id, QueuedMedia.Type type, String previewUrl, @Nullable String description) { - addMediaToQueue(id, type, null, Uri.parse(previewUrl), 0, - QueuedMedia.ReadyStage.UPLOADED, description); - } - - private void addMediaToQueue(@Nullable String id, QueuedMedia.Type type, Bitmap preview, Uri uri, - long mediaSize, QueuedMedia.ReadyStage readyStage, @Nullable String description) { - final QueuedMedia item = new QueuedMedia(type, uri, new ProgressImageView(this), - mediaSize, description); - item.id = id; - item.readyStage = readyStage; - ImageView view = item.preview; - Resources resources = getResources(); - int margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin); - int marginBottom = resources.getDimensionPixelSize( - R.dimen.compose_media_preview_margin_bottom); - LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize); - layoutParams.setMargins(margin, 0, margin, marginBottom); - view.setLayoutParams(layoutParams); - view.setScaleType(ImageView.ScaleType.CENTER_CROP); - if (preview != null) { - view.setImageBitmap(preview); - } else { - Glide.with(this) - .load(uri) - .placeholder(null) - .into(view); - } - view.setOnClickListener(v -> onMediaClick(item, v)); - mediaPreviewBar.addView(view); - mediaQueued.add(item); - updateContentDescription(item); - int queuedCount = mediaQueued.size(); - if (queuedCount == 1) { - // If there's one video in the queue it is full, so disable the button to queue more. - if (item.type == QueuedMedia.Type.VIDEO) { - enableButton(pickButton, false, false); - } - } else if (queuedCount >= Status.MAX_MEDIA_ATTACHMENTS) { - // Limit the total media attachments, also. - enableButton(pickButton, false, false); - } - - updateHideMediaToggle(); - enablePollButton(false); - - if (item.readyStage != QueuedMedia.ReadyStage.UPLOADED) { - waitForMediaLatch.countUp(); - - try { - if (type == QueuedMedia.Type.IMAGE && - (mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(getContentResolver(), item.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)) { - downsizeMedia(item); - } else { - uploadMedia(item); - } - } catch (IOException e) { - onUploadFailure(item, false); - } - } - } - - private void updateContentDescriptionForAllImages() { - List items = new ArrayList<>(mediaQueued); - for (QueuedMedia media : items) { - updateContentDescription(media); - } - } - - private void updateContentDescription(QueuedMedia item) { - if (item.preview != null) { - String imageId; - if (!TextUtils.isEmpty(item.description)) { - imageId = item.description; - } else { - int idx = getImageIdx(item); - if (idx < 0) - imageId = null; - else - imageId = Integer.toString(idx + 1); - } - item.preview.setContentDescription(getString(R.string.compose_preview_image_description, imageId)); - } - } - - private int getImageIdx(QueuedMedia item) { - return mediaQueued.indexOf(item); - } - - private void onMediaClick(QueuedMedia item, View view) { - PopupMenu popup = new PopupMenu(this, view); - final int addCaptionId = 1; - final int removeId = 2; - popup.getMenu().add(0, addCaptionId, 0, R.string.action_set_caption); - popup.getMenu().add(0, removeId, 0, R.string.action_remove); - popup.setOnMenuItemClickListener(menuItem -> { - switch (menuItem.getItemId()) { - case addCaptionId: - makeCaptionDialog(item); - break; - case removeId: - removeMediaFromQueue(item); - break; - } - return true; - }); - popup.show(); - } - - private void makeCaptionDialog(QueuedMedia item) { - LinearLayout dialogLayout = new LinearLayout(this); - int padding = Utils.dpToPx(this, 8); - dialogLayout.setPadding(padding, padding, padding, padding); - - dialogLayout.setOrientation(LinearLayout.VERTICAL); - ImageView imageView = new ImageView(this); - - DisplayMetrics displayMetrics = new DisplayMetrics(); - getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - - Single.fromCallable(() -> - getSampledBitmap(getContentResolver(), item.uri, displayMetrics.widthPixels, displayMetrics.heightPixels)) - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(new SingleObserver() { - @Override - public void onSubscribe(Disposable d) { - } - - @Override - public void onSuccess(Bitmap bitmap) { - imageView.setImageBitmap(bitmap); - } - - @Override - public void onError(Throwable e) { - } - }); - - - int margin = Utils.dpToPx(this, 4); - dialogLayout.addView(imageView); - ((LinearLayout.LayoutParams) imageView.getLayoutParams()).weight = 1; - imageView.getLayoutParams().height = 0; - ((LinearLayout.LayoutParams) imageView.getLayoutParams()).setMargins(0, margin, 0, 0); - - EditText input = new EditText(this); - input.setHint(getString(R.string.hint_describe_for_visually_impaired, MEDIA_DESCRIPTION_CHARACTER_LIMIT)); - dialogLayout.addView(input); - ((LinearLayout.LayoutParams) input.getLayoutParams()).setMargins(margin, margin, margin, margin); - input.setLines(2); - input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); - input.setText(item.description); - input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)}); - - DialogInterface.OnClickListener okListener = (dialog, which) -> { - Runnable updateDescription = () -> { - mastodonApi.updateMedia(item.id, input.getText().toString()).enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - Attachment attachment = response.body(); - if (response.isSuccessful() && attachment != null) { - item.description = attachment.getDescription(); - item.preview.setChecked(item.description != null && !item.description.isEmpty()); - dialog.dismiss(); - updateContentDescription(item); - } else { - showFailedCaptionMessage(); - } - item.updateDescription = null; - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - showFailedCaptionMessage(); - item.updateDescription = null; - } - }); - }; - - if (item.readyStage == QueuedMedia.ReadyStage.UPLOADED) { - updateDescription.run(); - } else { - // media is still uploading, queue description update for when it finishes - item.updateDescription = updateDescription; - } - }; - - AlertDialog dialog = new AlertDialog.Builder(this) - .setView(dialogLayout) - .setPositiveButton(android.R.string.ok, okListener) - .setNegativeButton(android.R.string.cancel, null) - .create(); - - Window window = dialog.getWindow(); - if (window != null) { - window.setSoftInputMode( - WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - } - - dialog.show(); - } - - private void showFailedCaptionMessage() { - Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show(); - } - - private void removeMediaFromQueue(QueuedMedia item) { - mediaPreviewBar.removeView(item.preview); - mediaQueued.remove(item); - if (mediaQueued.size() == 0) { - updateHideMediaToggle(); - enablePollButton(true); - } - updateContentDescriptionForAllImages(); - enableButton(pickButton, true, true); - cancelReadyingMedia(item); - } - - private void removeAllMediaFromQueue() { - for (Iterator it = mediaQueued.iterator(); it.hasNext(); ) { - QueuedMedia item = it.next(); - it.remove(); - removeMediaFromQueue(item); - } - } - - private void downsizeMedia(final QueuedMedia item) throws IOException { - item.readyStage = QueuedMedia.ReadyStage.DOWNSIZING; - - new DownsizeImageTask(STATUS_IMAGE_SIZE_LIMIT, getContentResolver(), createNewImageFile(), - new DownsizeImageTask.Listener() { - @Override - public void onSuccess(File tempFile) { - item.uri = FileProvider.getUriForFile( - ComposeActivity.this, - BuildConfig.APPLICATION_ID + ".fileprovider", - tempFile); - uploadMedia(item); - } - - @Override - public void onFailure() { - onMediaDownsizeFailure(item); - } - }).execute(item.uri); - } - - private void onMediaDownsizeFailure(QueuedMedia item) { - displayTransientError(R.string.error_image_upload_size); - removeMediaFromQueue(item); - } - - private void uploadMedia(final QueuedMedia item) { - item.readyStage = QueuedMedia.ReadyStage.UPLOADING; - - String mimeType = getContentResolver().getType(item.uri); - MimeTypeMap map = MimeTypeMap.getSingleton(); - String fileExtension = map.getExtensionFromMimeType(mimeType); - final String filename = String.format("%s_%s_%s.%s", - getString(R.string.app_name), - String.valueOf(new Date().getTime()), - StringUtils.randomAlphanumericString(10), - fileExtension); - - InputStream stream; - - try { - stream = getContentResolver().openInputStream(item.uri); - } catch (FileNotFoundException e) { - Log.w(TAG, e); - return; - } - - if (mimeType == null) mimeType = "multipart/form-data"; - - item.preview.setProgress(0); - - ProgressRequestBody fileBody = new ProgressRequestBody(stream, getMediaSize(getContentResolver(), item.uri), MediaType.parse(mimeType), - new ProgressRequestBody.UploadCallback() { // may reference activity longer than I would like to - int lastProgress = -1; - - @Override - public void onProgressUpdate(final int percentage) { - if (percentage != lastProgress) { - runOnUiThread(() -> item.preview.setProgress(percentage)); - } - lastProgress = percentage; - } - }); - - MultipartBody.Part body = MultipartBody.Part.createFormData("file", filename, fileBody); - - item.uploadRequest = mastodonApi.uploadMedia(body); - - item.uploadRequest.enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { - if (response.isSuccessful()) { - onUploadSuccess(item, response.body()); - if (item.updateDescription != null) { - item.updateDescription.run(); - } - } else { - Log.d(TAG, "Upload request failed. " + response.message()); - onUploadFailure(item, call.isCanceled()); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(TAG, "Upload request failed. " + t.getMessage()); - onUploadFailure(item, call.isCanceled()); - item.updateDescription = null; - } - }); - } - - private void onUploadSuccess(final QueuedMedia item, Attachment media) { - item.id = media.getId(); - item.preview.setProgress(-1); - item.readyStage = QueuedMedia.ReadyStage.UPLOADED; - - waitForMediaLatch.countDown(); - } - - private void onUploadFailure(QueuedMedia item, boolean isCanceled) { - if (!isCanceled) { - /* if the upload was voluntarily cancelled, such as if the user clicked on it to remove - * it from the queue, then don't display this error message. */ - displayTransientError(R.string.error_media_upload_sending); - } - if (finishingUploadDialog != null && finishingUploadDialog.isShowing()) { - finishingUploadDialog.cancel(); - } - if (!isCanceled) { - // If it is canceled, it's already been removed, otherwise do it. - removeMediaFromQueue(item); - } - } - - private void cancelReadyingMedia(QueuedMedia item) { - if (item.readyStage == QueuedMedia.ReadyStage.UPLOADING) { - item.uploadRequest.cancel(); - } - if (item.id == null) { - /* The presence of an upload id is used to detect if it finished uploading or not, to - * prevent counting down twice on the same media item. */ - waitForMediaLatch.countDown(); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent intent) { - super.onActivityResult(requestCode, resultCode, intent); - if (resultCode == RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { - Uri uri = intent.getData(); - long mediaSize = getMediaSize(getContentResolver(), uri); - pickMedia(uri, mediaSize, null); - } else if (resultCode == RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { - long mediaSize = getMediaSize(getContentResolver(), photoUploadUri); - pickMedia(photoUploadUri, mediaSize, null); - } - } - - - private void pickMedia(Uri inUri, long mediaSize, String description) { - Uri uri = inUri; - ContentResolver contentResolver = getContentResolver(); - String mimeType = contentResolver.getType(uri); - - InputStream tempInput = null; - FileOutputStream out = null; - String filename = inUri.toString().substring(inUri.toString().lastIndexOf("/")); - int suffixPosition = filename.lastIndexOf("."); - String suffix = ""; - if(suffixPosition > 0) suffix = filename.substring(suffixPosition); - try { - tempInput = getContentResolver().openInputStream(inUri); - File file = File.createTempFile("randomTemp1", suffix, getCacheDir()); - out = new FileOutputStream(file.getAbsoluteFile()); - byte[] buff = new byte[1024]; - int read = 0; - while ((read = tempInput.read(buff)) > 0) { - out.write(buff, 0, read); - } - uri = FileProvider.getUriForFile(this, - BuildConfig.APPLICATION_ID+".fileprovider", - file); - mediaSize = getMediaSize(getContentResolver(), uri); - tempInput.close(); - out.close(); - } catch(IOException e) { - Log.w(TAG, e); - uri = inUri; - } finally { - IOUtils.closeQuietly(tempInput); - IOUtils.closeQuietly(out); - } - - if (mediaSize == MEDIA_SIZE_UNKNOWN) { - displayTransientError(R.string.error_media_upload_opening); - return; - } - if (mimeType != null) { - String topLevelType = mimeType.substring(0, mimeType.indexOf('/')); - switch (topLevelType) { - case "video": { - if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { - displayTransientError(R.string.error_video_upload_size); - return; - } - if (mediaQueued.size() > 0 - && mediaQueued.get(0).type == QueuedMedia.Type.IMAGE) { - displayTransientError(R.string.error_media_upload_image_or_video); - return; - } - Bitmap bitmap = getVideoThumbnail(this, uri, thumbnailViewSize); - if (bitmap != null) { - addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize, description); - } else { - displayTransientError(R.string.error_media_upload_opening); - } - break; - } - case "image": { - Bitmap bitmap = getImageThumbnail(contentResolver, uri, thumbnailViewSize); - if (bitmap != null) { - addMediaToQueue(QueuedMedia.Type.IMAGE, bitmap, uri, mediaSize, description); - } else { - displayTransientError(R.string.error_media_upload_opening); - } - break; - } - default: { - displayTransientError(R.string.error_media_upload_type); - break; - } - } - } else { - displayTransientError(R.string.error_media_upload_type); - } - } - - private void showContentWarning(boolean show) { - statusHideText = show; - TransitionManager.beginDelayedTransition((ViewGroup) contentWarningBar.getParent()); - int color; - if (show) { - statusMarkSensitive = true; - contentWarningBar.setVisibility(View.VISIBLE); - contentWarningEditor.setSelection(contentWarningEditor.getText().length()); - contentWarningEditor.requestFocus(); - color = ContextCompat.getColor(this, R.color.tusky_blue); - } else { - contentWarningBar.setVisibility(View.GONE); - textEditor.requestFocus(); - color = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - } - contentWarningButton.getDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN); - - updateHideMediaToggle(); - - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - handleCloseButton(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - - @Override - public void onBackPressed() { - // Acting like a teen: deliberately ignoring parent. - if (composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED || - addMediaBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED || - emojiBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED || - scheduleBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - return; - } - - handleCloseButton(); - } - - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - Log.d(TAG, event.toString()); - if (event.isCtrlPressed()) { - if (keyCode == KeyEvent.KEYCODE_ENTER) { - // send toot by pressing CTRL + ENTER - this.onSendClicked(); - return true; - } - } - - if (keyCode == KeyEvent.KEYCODE_BACK) { - onBackPressed(); - return true; - } - - return super.onKeyDown(keyCode, event); - } - - private void handleCloseButton() { - - CharSequence contentText = textEditor.getText(); - CharSequence contentWarning = contentWarningEditor.getText(); - - boolean textChanged = !(TextUtils.isEmpty(contentText) || startingText.startsWith(contentText.toString())); - boolean contentWarningChanged = contentWarningBar.getVisibility() == View.VISIBLE && - !TextUtils.isEmpty(contentWarning) && !startingContentWarning.startsWith(contentWarning.toString()); - boolean mediaChanged = !mediaQueued.isEmpty(); - boolean pollChanged = poll != null; - - if (textChanged || contentWarningChanged || mediaChanged || pollChanged) { - new AlertDialog.Builder(this) - .setMessage(R.string.compose_save_draft) - .setPositiveButton(R.string.action_save, (d, w) -> saveDraftAndFinish()) - .setNegativeButton(R.string.action_delete, (d, w) -> deleteDraftAndFinish()) - .show(); - } else { - finishWithoutSlideOutAnimation(); - } - } - - private void deleteDraftAndFinish() { - for (QueuedMedia media : mediaQueued) { - if (media.uploadRequest != null) - media.uploadRequest.cancel(); - } - finishWithoutSlideOutAnimation(); - } - - private void saveDraftAndFinish() { - ArrayList mediaUris = new ArrayList<>(); - ArrayList mediaDescriptions = new ArrayList<>(); - for (QueuedMedia item : mediaQueued) { - mediaUris.add(item.uri.toString()); - mediaDescriptions.add(item.description); - } - - saveTootHelper.saveToot(textEditor.getText().toString(), - contentWarningEditor.getText().toString(), - getIntent().getStringExtra("saved_json_urls"), - mediaUris, - mediaDescriptions, - savedTootUid, - inReplyToId, - getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), - getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), - statusVisibility, - poll); - finishWithoutSlideOutAnimation(); - } - - @Override - public List search(String token) { - switch (token.charAt(0)) { - case '@': - try { - List accountList = mastodonApi - .searchAccounts(token.substring(1), false, 20, null) - .blockingGet(); - return CollectionsKt.map(accountList, - ComposeAutoCompleteAdapter.AccountResult::new); - } catch (Throwable e) { - return Collections.emptyList(); - } - case '#': - try { - SearchResult searchResults = mastodonApi.searchObservable(token, null, false, null, null, null) - .blockingGet(); - return CollectionsKt.map( - searchResults.getHashtags(), - ComposeAutoCompleteAdapter.HashtagResult::new - ); - } catch (Throwable e) { - Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e); - return Collections.emptyList(); - } - case ':': - try { - emojiListRetrievalLatch.await(); - } catch (InterruptedException e) { - Log.e(TAG, String.format("Autocomplete search for %s was interrupted.", token)); - return Collections.emptyList(); - } - if (emojiList != null) { - String incomplete = token.substring(1).toLowerCase(); - - List results = - new ArrayList<>(); - List resultsInside = - new ArrayList<>(); - - for (Emoji emoji : emojiList) { - String shortcode = emoji.getShortcode().toLowerCase(); - - if (shortcode.startsWith(incomplete)) { - results.add(new ComposeAutoCompleteAdapter.EmojiResult(emoji)); - } else if (shortcode.indexOf(incomplete, 1) != -1) { - resultsInside.add(new ComposeAutoCompleteAdapter.EmojiResult(emoji)); - } - } - - if (!results.isEmpty() && !resultsInside.isEmpty()) { - // both lists have results. include a separator between them. - results.add(new ComposeAutoCompleteAdapter.ResultSeparator()); - } - - results.addAll(resultsInside); - return results; - } else { - return Collections.emptyList(); - } - default: - Log.w(TAG, "Unexpected autocompletion token: " + token); - return Collections.emptyList(); - } - } - - @Override - public void onEmojiSelected(@NotNull String shortcode) { - replaceTextAtCaret(":" + shortcode + ": "); - } - - private void loadCachedInstanceMetadata(@NotNull AccountEntity activeAccount) { - InstanceEntity instanceEntity = database.instanceDao() - .loadMetadataForInstance(activeAccount.getDomain()); - - if (instanceEntity != null) { - Integer max = instanceEntity.getMaximumTootCharacters(); - maximumTootCharacters = (max == null ? STATUS_CHARACTER_LIMIT : max); - maxPollOptions = instanceEntity.getMaxPollOptions(); - maxPollOptionLength = instanceEntity.getMaxPollOptionLength(); - setEmojiList(instanceEntity.getEmojiList()); - updateVisibleCharactersLeft(); - } - } - - private void setEmojiList(@Nullable List emojiList) { - this.emojiList = emojiList; - - emojiListRetrievalLatch.countDown(); - - if (emojiList != null) { - emojiView.setAdapter(new EmojiAdapter(emojiList, ComposeActivity.this)); - enableButton(emojiButton, true, emojiList.size() > 0); - } - } - - private void cacheInstanceMetadata(@NotNull AccountEntity activeAccount) { - InstanceEntity instanceEntity = new InstanceEntity( - activeAccount.getDomain(), emojiList, maximumTootCharacters, maxPollOptions, maxPollOptionLength - ); - database.instanceDao().insertOrReplace(instanceEntity); - } - - // Accessors for testing, hence package scope - int getMaximumTootCharacters() { - return maximumTootCharacters; - } - - static boolean canHandleMimeType(@Nullable String mimeType) { - return (mimeType != null && - (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.equals("text/plain"))); - } - - private void onFetchInstanceSuccess(Instance instance) { - if (instance != null) { - - if (instance.getMaxTootChars() != null) { - maximumTootCharacters = instance.getMaxTootChars(); - updateVisibleCharactersLeft(); - } - - if (!new VersionUtils(instance.getVersion()).supportsScheduledToots()) { - scheduleButton.setVisibility(View.GONE); - } - - if (instance.getPollLimits() != null) { - maxPollOptions = instance.getPollLimits().getMaxOptions(); - maxPollOptionLength = instance.getPollLimits().getMaxOptionChars(); - } - - cacheInstanceMetadata(accountManager.getActiveAccount()); - } - } - - private void onFetchInstanceFailure(Throwable throwable) { - Log.w(TAG, "error loading instance data", throwable); - loadCachedInstanceMetadata(accountManager.getActiveAccount()); - } - - public static final class QueuedMedia { - Type type; - ProgressImageView preview; - Uri uri; - String id; - Call uploadRequest; - ReadyStage readyStage; - long mediaSize; - String description; - Runnable updateDescription; - - QueuedMedia(Type type, Uri uri, ProgressImageView preview, long mediaSize, - String description) { - this.type = type; - this.uri = uri; - this.preview = preview; - this.mediaSize = mediaSize; - this.description = description; - } - - public enum Type { - IMAGE, - VIDEO - } - - enum ReadyStage { - DOWNSIZING, - UPLOADING, - UPLOADED - } - } - - /** - * This saves enough information to re-enqueue an attachment when restoring the activity. - */ - private static class SavedQueuedMedia implements Parcelable { - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - public SavedQueuedMedia createFromParcel(Parcel in) { - return new SavedQueuedMedia(in); - } - - public SavedQueuedMedia[] newArray(int size) { - return new SavedQueuedMedia[size]; - } - }; - String id; - QueuedMedia.Type type; - Uri uri; - long mediaSize; - QueuedMedia.ReadyStage readyStage; - String description; - - SavedQueuedMedia(String id, QueuedMedia.Type type, Uri uri, long mediaSize, QueuedMedia.ReadyStage readyStage, String description) { - this.id = id; - this.type = type; - this.uri = uri; - this.mediaSize = mediaSize; - this.readyStage = readyStage; - this.description = description; - } - - SavedQueuedMedia(Parcel parcel) { - id = parcel.readString(); - type = (QueuedMedia.Type) parcel.readSerializable(); - uri = parcel.readParcelable(Uri.class.getClassLoader()); - mediaSize = parcel.readLong(); - readyStage = QueuedMedia.ReadyStage.valueOf(parcel.readString()); - description = parcel.readString(); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(id); - dest.writeSerializable(type); - dest.writeParcelable(uri, flags); - dest.writeLong(mediaSize); - dest.writeString(readyStage.name()); - dest.writeString(description); - } - } - - @Override - public void onTimeSet(TimePicker view, int hourOfDay, int minute) { - scheduleView.onTimeSet(hourOfDay, minute); - updateScheduleButton(); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - - public void resetSchedule() { - scheduleView.resetSchedule(); - updateScheduleButton(); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - - public static final class IntentBuilder { - @Nullable - private Integer savedTootUid; - @Nullable - private String tootText; - @Nullable - private String savedJsonUrls; - @Nullable - private String savedJsonDescriptions; - @Nullable - private Collection mentionedUsernames; - @Nullable - private String inReplyToId; - @Nullable - private Status.Visibility replyVisibility; - @Nullable - private Status.Visibility visibility; - @Nullable - private String contentWarning; - @Nullable - private String replyingStatusAuthor; - @Nullable - private String replyingStatusContent; - @Nullable - private ArrayList mediaAttachments; - @Nullable - private String scheduledAt; - @Nullable - private Boolean sensitive; - @Nullable - private NewPoll poll; - - public IntentBuilder savedTootUid(int uid) { - this.savedTootUid = uid; - return this; - } - - public IntentBuilder tootText(String tootText) { - this.tootText = tootText; - return this; - } - - public IntentBuilder savedJsonUrls(String jsonUrls) { - this.savedJsonUrls = jsonUrls; - return this; - } - - public IntentBuilder savedJsonDescriptions(String jsonDescriptions) { - this.savedJsonDescriptions = jsonDescriptions; - return this; - } - - public IntentBuilder visibility(Status.Visibility visibility) { - this.visibility = visibility; - return this; - } - - public IntentBuilder mentionedUsernames(Collection mentionedUsernames) { - this.mentionedUsernames = mentionedUsernames; - return this; - } - - public IntentBuilder inReplyToId(String inReplyToId) { - this.inReplyToId = inReplyToId; - return this; - } - - public IntentBuilder replyVisibility(Status.Visibility replyVisibility) { - this.replyVisibility = replyVisibility; - return this; - } - - public IntentBuilder contentWarning(String contentWarning) { - this.contentWarning = contentWarning; - return this; - } - - public IntentBuilder replyingStatusAuthor(String username) { - this.replyingStatusAuthor = username; - return this; - } - - public IntentBuilder replyingStatusContent(String content) { - this.replyingStatusContent = content; - return this; - } - - public IntentBuilder mediaAttachments(ArrayList mediaAttachments) { - this.mediaAttachments = mediaAttachments; - return this; - } - - public IntentBuilder scheduledAt(String scheduledAt) { - this.scheduledAt = scheduledAt; - return this; - } - - public IntentBuilder sensitive(boolean sensitive) { - this.sensitive = sensitive; - return this; - } - - public IntentBuilder poll(NewPoll poll) { - this.poll = poll; - return this; - } - - public Intent build(Context context) { - Intent intent = new Intent(context, ComposeActivity.class); - - if (savedTootUid != null) { - intent.putExtra(SAVED_TOOT_UID_EXTRA, (int) savedTootUid); - } - if (tootText != null) { - intent.putExtra(TOOT_TEXT_EXTRA, tootText); - } - if (savedJsonUrls != null) { - intent.putExtra(SAVED_JSON_URLS_EXTRA, savedJsonUrls); - } - if (savedJsonDescriptions != null) { - intent.putExtra(SAVED_JSON_DESCRIPTIONS_EXTRA, savedJsonDescriptions); - } - if (mentionedUsernames != null) { - String[] usernames = mentionedUsernames.toArray(new String[0]); - intent.putExtra(MENTIONED_USERNAMES_EXTRA, usernames); - } - if (inReplyToId != null) { - intent.putExtra(IN_REPLY_TO_ID_EXTRA, inReplyToId); - } - if (replyVisibility != null) { - intent.putExtra(REPLY_VISIBILITY_EXTRA, replyVisibility.getNum()); - } - if (visibility != null) { - intent.putExtra(TOOT_VISIBILITY_EXTRA, visibility.getNum()); - } - if (contentWarning != null) { - intent.putExtra(CONTENT_WARNING_EXTRA, contentWarning); - } - if (replyingStatusContent != null) { - intent.putExtra(REPLYING_STATUS_CONTENT_EXTRA, replyingStatusContent); - } - if (replyingStatusAuthor != null) { - intent.putExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA, replyingStatusAuthor); - } - if (mediaAttachments != null) { - intent.putParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA, mediaAttachments); - } - if (scheduledAt != null) { - intent.putExtra(SCHEDULED_AT_EXTRA, scheduledAt); - } - if (sensitive != null) { - intent.putExtra(SENSITIVE_EXTRA, sensitive); - } - if (poll != null) { - intent.putExtra(POLL_EXTRA, poll); - } - return intent; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index 78d80d046..184e6a61a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -44,6 +44,7 @@ import com.keylesspalace.tusky.appstore.CacheUpdater; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.MainTabsChangedEvent; import com.keylesspalace.tusky.appstore.ProfileEditedEvent; +import com.keylesspalace.tusky.components.compose.ComposeActivity; import com.keylesspalace.tusky.components.conversation.ConversationsRepository; import com.keylesspalace.tusky.components.search.SearchActivity; import com.keylesspalace.tusky.db.AccountEntity; diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java index 8bf6565bb..c40639c2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java @@ -22,21 +22,6 @@ import android.view.MenuItem; import android.view.View; import android.widget.TextView; -import com.keylesspalace.tusky.adapter.SavedTootAdapter; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.StatusComposedEvent; -import com.keylesspalace.tusky.db.AppDatabase; -import com.keylesspalace.tusky.db.TootDao; -import com.keylesspalace.tusky.db.TootEntity; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.util.SaveTootHelper; - -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.List; - -import javax.inject.Inject; - import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; @@ -44,16 +29,35 @@ import androidx.lifecycle.Lifecycle; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.keylesspalace.tusky.adapter.SavedTootAdapter; +import com.keylesspalace.tusky.appstore.EventHub; +import com.keylesspalace.tusky.appstore.StatusComposedEvent; +import com.keylesspalace.tusky.components.compose.ComposeActivity; +import com.keylesspalace.tusky.db.AppDatabase; +import com.keylesspalace.tusky.db.TootDao; +import com.keylesspalace.tusky.db.TootEntity; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.util.SaveTootHelper; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; + import io.reactivex.android.schedulers.AndroidSchedulers; +import static com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; import static com.uber.autodispose.AutoDispose.autoDisposable; import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; public final class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction, Injectable { - private SaveTootHelper saveTootHelper; - // ui private SavedTootAdapter adapter; private TextView noContent; @@ -66,13 +70,13 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd EventHub eventHub; @Inject AppDatabase database; + @Inject + SaveTootHelper saveTootHelper; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - saveTootHelper = new SaveTootHelper(database.tootDao(), this); - eventHub.getEvents() .observeOn(AndroidSchedulers.mainThread()) .ofType(StatusComposedEvent.class) @@ -153,18 +157,29 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd @Override public void click(int position, TootEntity item) { - Intent intent = new ComposeActivity.IntentBuilder() - .savedTootUid(item.getUid()) - .tootText(item.getText()) - .contentWarning(item.getContentWarning()) - .savedJsonUrls(item.getUrls()) - .savedJsonDescriptions(item.getDescriptions()) - .inReplyToId(item.getInReplyToId()) - .replyingStatusAuthor(item.getInReplyToUsername()) - .replyingStatusContent(item.getInReplyToText()) - .visibility(item.getVisibility()) - .poll(item.getPoll()) - .build(this); + Gson gson = new Gson(); + Type stringListType = new TypeToken>() {}.getType(); + List jsonUrls = gson.fromJson(item.getUrls(), stringListType); + List descriptions = gson.fromJson(item.getDescriptions(), stringListType); + + ComposeOptions composeOptions = new ComposeOptions( + item.getUid(), + item.getText(), + jsonUrls, + descriptions, + /*mentionedUsernames*/null, + item.getInReplyToId(), + /*replyVisibility*/null, + item.getVisibility(), + item.getContentWarning(), + item.getInReplyToUsername(), + item.getInReplyToText(), + /*mediaAttachments*/null, + /*scheduledAt*/null, + /*sensitive*/null, + /*poll*/null + ); + Intent intent = ComposeActivity.startIntent(this, composeOptions); startActivity(intent); } diff --git a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt index a7de7a2bf..0bdd14e8a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt @@ -11,6 +11,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.keylesspalace.tusky.adapter.ScheduledTootAdapter import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusScheduledEvent +import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi @@ -135,15 +136,15 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootAdapter.ScheduledToot if (item == null) { return } - val intent = ComposeActivity.IntentBuilder() - .tootText(item.params.text) - .contentWarning(item.params.spoilerText) - .mediaAttachments(item.mediaAttachments) - .inReplyToId(item.params.inReplyToId) - .visibility(item.params.visibility) - .scheduledAt(item.scheduledAt) - .sensitive(item.params.sensitive) - .build(this) + val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions( + tootText = item.params.text, + contentWarning = item.params.spoilerText, + mediaAttachments = item.mediaAttachments, + inReplyToId = item.params.inReplyToId, + visibility = item.params.visibility, + scheduledAt = item.scheduledAt, + sensitive = item.params.sensitive + )) startActivity(intent) delete(position, item) } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index e8b484ca8..6bf824613 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -72,7 +72,7 @@ public class TuskyApplication extends Application implements HasAndroidInjector AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13, AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, - AppDatabase.MIGRATION_19_20) + AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21) .build(); accountManager = new AccountManager(appDatabase); serviceLocator = new ServiceLocator() { 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 new file mode 100644 index 000000000..99d43c655 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -0,0 +1,994 @@ +/* Copyright 2019 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.Manifest +import android.app.Activity +import android.app.ProgressDialog +import android.app.TimePickerDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import androidx.preference.PreferenceManager +import android.provider.MediaStore +import android.text.TextUtils +import android.util.Log +import android.view.KeyEvent +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.view.inputmethod.InputConnectionCompat +import androidx.core.view.inputmethod.InputContentInfoCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.transition.TransitionManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.adapter.EmojiAdapter +import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener +import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog +import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog +import com.mikepenz.google_material_typeface_library.GoogleMaterial +import com.mikepenz.iconics.IconicsDrawable +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.activity_compose.* +import java.io.File +import java.io.IOException +import java.util.* +import javax.inject.Inject +import kotlin.collections.ArrayList +import kotlin.math.max +import kotlin.math.min + +class ComposeActivity : BaseActivity(), + ComposeOptionsListener, + ComposeAutoCompleteAdapter.AutocompletionProvider, + OnEmojiSelectedListener, + Injectable, + InputConnectionCompat.OnCommitContentListener, + TimePickerDialog.OnTimeSetListener { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var composeOptionsBehavior: BottomSheetBehavior<*> + private lateinit var addMediaBehavior: BottomSheetBehavior<*> + private lateinit var emojiBehavior: BottomSheetBehavior<*> + private lateinit var scheduleBehavior: BottomSheetBehavior<*> + + // this only exists when a status is trying to be sent, but uploads are still occurring + private var finishingUploadDialog: ProgressDialog? = null + private var currentInputContentInfo: InputContentInfoCompat? = null + private var currentFlags: Int = 0 + private var photoUploadUri: Uri? = null + @VisibleForTesting + var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT + + private var composeOptions: ComposeOptions? = null + private lateinit var viewModel: ComposeViewModel + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) + if (theme == "black") { + setTheme(R.style.TuskyDialogActivityBlackTheme) + } + setContentView(R.layout.activity_compose) + + setupActionBar() + // do not do anything when not logged in, activity will be finished in super.onCreate() anyway + val activeAccount = accountManager.activeAccount ?: return + + setupAvatar(preferences, activeAccount) + val mediaAdapter = MediaPreviewAdapter( + this, + onAddCaption = { item -> + makeCaptionDialog(item.description, item.uri) { newDescription -> + viewModel.updateDescription(item.localId, newDescription) + } + }, + onRemove = this::removeMediaFromQueue + ) + composeMediaPreviewBar.layoutManager = + LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) + composeMediaPreviewBar.adapter = mediaAdapter + composeMediaPreviewBar.itemAnimator = null + + viewModel = ViewModelProviders.of(this, viewModelFactory)[ComposeViewModel::class.java] + + subscribeToUpdates(mediaAdapter) + setupButtons() + + /* If the composer is started up as a reply to another post, override the "starting" state + * based on what the intent from the reply request passes. */ + if (intent != null) { + this.composeOptions = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) + viewModel.setup(composeOptions) + setupReplyViews(composeOptions?.replyingStatusAuthor) + val tootText = composeOptions?.tootText + if (!tootText.isNullOrEmpty()) { + composeEditField.setText(tootText) + } + } + + if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) { + composeScheduleView.setDateTime(composeOptions?.scheduledAt) + } + + setupComposeField(viewModel.startingText) + setupContentWarningField(composeOptions?.contentWarning) + setupPollView() + applyShareIntent(intent, savedInstanceState) + + composeEditField.requestFocus() + } + + private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) { + if (intent != null && savedInstanceState == null) { + /* Get incoming images being sent through a share action from another app. Only do this + * when savedInstanceState is null, otherwise both the images from the intent and the + * instance state will be re-queued. */ + val type = intent.type + if (type != null) { + if (type.startsWith("image/") || type.startsWith("video/")) { + val uriList = ArrayList() + if (intent.action != null) { + when (intent.action) { + Intent.ACTION_SEND -> { + val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (uri != null) { + uriList.add(uri) + } + } + Intent.ACTION_SEND_MULTIPLE -> { + val list = intent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM) + if (list != null) { + for (uri in list) { + if (uri != null) { + uriList.add(uri) + } + } + } + } + } + } + for (uri in uriList) { + pickMedia(uri) + } + } else if (type == "text/plain") { + val action = intent.action + if (action != null && action == Intent.ACTION_SEND) { + val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) + val text = intent.getStringExtra(Intent.EXTRA_TEXT) + val shareBody = if (subject != null && text != null) { + if (subject != text && !text.contains(subject)) { + String.format("%s\n%s", subject, text) + } else { + text + } + } else text ?: subject + + if (shareBody != null) { + val start = composeEditField.selectionStart.coerceAtLeast(0) + val end = composeEditField.selectionEnd.coerceAtLeast(0) + val left = min(start, end) + val right = max(start, end) + composeEditField.text.replace(left, right, shareBody, 0, shareBody.length) + } + } + } + } + } + } + + private fun setupReplyViews(replyingStatusAuthor: String?) { + if (replyingStatusAuthor != null) { + composeReplyView.show() + composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) + val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).sizeDp(12) + + ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) + composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null) + + composeReplyView.setOnClickListener { + TransitionManager.beginDelayedTransition(composeReplyContentView.parent as ViewGroup) + + if (composeReplyContentView.isVisible) { + composeReplyContentView.hide() + composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null) + } else { + composeReplyContentView.show() + val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).sizeDp(12) + + ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary) + composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null) + } + } + } + composeOptions?.replyingStatusContent?.let { composeReplyContentView.text = it } + } + + private fun setupContentWarningField(startingContentWarning: String?) { + if (startingContentWarning != null) { + composeContentWarningField.setText(startingContentWarning) + } + composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } + } + + private fun setupComposeField(startingText: String?) { + composeEditField.setOnCommitContentListener(this) + + composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } + + composeEditField.setAdapter( + ComposeAutoCompleteAdapter(this)) + composeEditField.setTokenizer(ComposeTokenizer()) + + composeEditField.setText(startingText) + composeEditField.setSelection(composeEditField.length()) + + val mentionColour = composeEditField.linkTextColors.defaultColor + highlightSpans(composeEditField.text, mentionColour) + composeEditField.afterTextChanged { editable -> + highlightSpans(editable, mentionColour) + updateVisibleCharactersLeft() + } + + // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O + || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { + composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + } + } + + private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { + withLifecycleContext { + viewModel.instanceParams.observe { instanceData -> + maximumTootCharacters = instanceData.maxChars + updateVisibleCharactersLeft() + composeScheduleButton.visible(instanceData.supportsScheduled) + } + viewModel.emoji.observe { emoji -> setEmojiList(emoji) } + combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning -> + updateSensitiveMediaToggle(markSensitive, showContentWarning) + showContentWarning(showContentWarning) + }.subscribe() + viewModel.statusVisibility.observe { visibility -> + setStatusVisibility(visibility) + } + viewModel.media.observe { media -> + composeMediaPreviewBar.visible(media.isNotEmpty()) + mediaAdapter.submitList(media) + updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false) + } + viewModel.poll.observe { poll -> + pollPreview.visible(poll != null) + poll?.let(pollPreview::setPoll) + } + viewModel.scheduledAt.observe {scheduledAt -> + if(scheduledAt == null) { + composeScheduleView.resetSchedule() + } else { + composeScheduleView.setDateTime(scheduledAt) + } + updateScheduleButton() + } + combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll -> + val active = poll == null + && media!!.size != 4 + && media.firstOrNull()?.type != QueuedMedia.Type.VIDEO + enableButton(composeAddMediaButton, active, active) + enablePollButton(media.isNullOrEmpty()) + }.subscribe() + viewModel.uploadError.observe { + displayTransientError(R.string.error_media_upload_sending) + } + } + } + + private fun setupButtons() { + composeOptionsBottomSheet.listener = this + + composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsBottomSheet) + addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet) + scheduleBehavior = BottomSheetBehavior.from(composeScheduleView) + emojiBehavior = BottomSheetBehavior.from(emojiView) + + emojiView.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false) + enableButton(composeEmojiButton, clickable = false, colorActive = false) + + // Setup the interface buttons. + composeTootButton.setOnClickListener { onSendClicked() } + composeAddMediaButton.setOnClickListener { openPickDialog() } + composeToggleVisibilityButton.setOnClickListener { showComposeOptions() } + composeContentWarningButton.setOnClickListener { onContentWarningChanged() } + composeEmojiButton.setOnClickListener { showEmojis() } + composeHideMediaButton.setOnClickListener { toggleHideMedia() } + composeScheduleButton.setOnClickListener { onScheduleClick() } + composeScheduleView.setResetOnClickListener { resetSchedule() } + atButton.setOnClickListener { atButtonClicked() } + hashButton.setOnClickListener { hashButtonClicked() } + + val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + + val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).color(textColor).sizeDp(18) + actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null) + + val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18) + actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null) + + val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).color(textColor).sizeDp(18) + addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null) + + actionPhotoTake.setOnClickListener { initiateCameraApp() } + actionPhotoPick.setOnClickListener { onMediaPick() } + addPollTextActionTextView.setOnClickListener { openPollDialog() } + } + + private fun setupActionBar() { + setSupportActionBar(toolbar) + supportActionBar?.run { + title = null + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + val closeIcon = AppCompatResources.getDrawable(this@ComposeActivity, R.drawable.ic_close_24dp) + ThemeUtils.setDrawableTint(this@ComposeActivity, closeIcon!!, R.attr.compose_close_button_tint) + setHomeAsUpIndicator(closeIcon) + } + + } + + private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { + val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize) + val a = obtainStyledAttributes(null, actionBarSizeAttr) + val avatarSize = a.getDimensionPixelSize(0, 1) + a.recycle() + + val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + loadAvatar( + activeAccount.profilePictureUrl, + composeAvatar, + avatarSize / 8, + animateAvatars + ) + composeAvatar.contentDescription = getString(R.string.compose_active_account_description, + activeAccount.fullName) + } + + private fun replaceTextAtCaret(text: CharSequence) { + // If you select "backward" in an editable, you get SelectionStart > SelectionEnd + val start = composeEditField.selectionStart.coerceAtMost(composeEditField.selectionEnd) + val end = composeEditField.selectionStart.coerceAtLeast(composeEditField.selectionEnd) + composeEditField.text.replace(start, end, text) + + // Set the cursor after the inserted text + composeEditField.setSelection(start + text.length) + } + + private fun atButtonClicked() { + replaceTextAtCaret("@") + } + + private fun hashButtonClicked() { + replaceTextAtCaret("#") + } + + override fun onSaveInstanceState(outState: Bundle) { + if (currentInputContentInfo != null) { + outState.putParcelable("commitContentInputContentInfo", + currentInputContentInfo!!.unwrap() as Parcelable?) + outState.putInt("commitContentFlags", currentFlags) + } + currentInputContentInfo = null + currentFlags = 0 + outState.putParcelable("photoUploadUri", photoUploadUri) + super.onSaveInstanceState(outState) + } + + private fun displayTransientError(@StringRes stringId: Int) { + val bar = Snackbar.make(activityCompose, stringId, Snackbar.LENGTH_LONG) + //necessary so snackbar is shown over everything + bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + bar.show() + } + + private fun toggleHideMedia() { + this.viewModel.toggleMarkSensitive() + } + + private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) { + TransitionManager.beginDelayedTransition(composeHideMediaButton.parent as ViewGroup) + + if (viewModel.media.value.isNullOrEmpty()) { + composeHideMediaButton.hide() + } else { + composeHideMediaButton.show() + @ColorInt val color = if (contentWarningShown) { + composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) + composeHideMediaButton.isClickable = false + ContextCompat.getColor(this, R.color.compose_media_visible_button_disabled_blue) + + } else { + composeHideMediaButton.isClickable = true + if (markMediaSensitive) { + composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) + ContextCompat.getColor(this, R.color.tusky_blue) + } else { + composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } + } + composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + } + } + + private fun updateScheduleButton() { + @ColorInt val color = if (composeScheduleView.time == null) { + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } else { + ContextCompat.getColor(this, R.color.tusky_blue) + } + composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + } + + private fun enableButtons(enable: Boolean) { + composeAddMediaButton.isClickable = enable + composeToggleVisibilityButton.isClickable = enable + composeEmojiButton.isClickable = enable + composeHideMediaButton.isClickable = enable + composeScheduleButton.isClickable = enable + composeTootButton.isEnabled = enable + } + + private fun setStatusVisibility(visibility: Status.Visibility) { + composeOptionsBottomSheet.setStatusVisibility(visibility) + composeTootButton.setStatusVisibility(visibility) + + val iconRes = when (visibility) { + Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp + Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp + Status.Visibility.DIRECT -> R.drawable.ic_email_24dp + Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp + else -> R.drawable.ic_lock_open_24dp + } + val drawable = ThemeUtils.getTintedDrawable(this, iconRes, android.R.attr.textColorTertiary) + composeToggleVisibilityButton.setImageDrawable(drawable) + } + + private fun showComposeOptions() { + if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_EXPANDED + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } else { + composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + + private fun onScheduleClick() { + if(viewModel.scheduledAt.value == null) { + composeScheduleView.openPickDateDialog() + } else { + showScheduleView() + } + } + + private fun showScheduleView() { + if (scheduleBehavior.state == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + scheduleBehavior.state = BottomSheetBehavior.STATE_EXPANDED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } else { + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + + private fun showEmojis() { + emojiView.adapter?.let { + if (it.itemCount == 0) { + val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain) + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } else { + if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } else { + emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + } + } + + private fun openPickDialog() { + if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } else { + addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + + private fun onMediaPick() { + addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + //Wait until bottom sheet is not collapsed and show next screen after + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.removeBottomSheetCallback(this) + if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this@ComposeActivity, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) + } else { + initiateMediaPicking() + } + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + } + ) + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + + private fun openPollDialog() { + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + val instanceParams = viewModel.instanceParams.value!! + showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions, + instanceParams.pollMaxLength, viewModel::updatePoll) + } + + private fun setupPollView() { + val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin) + val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + + val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + layoutParams.setMargins(margin, margin, margin, marginBottom) + pollPreview.layoutParams = layoutParams + + pollPreview.setOnClickListener { + val popup = PopupMenu(this, pollPreview) + val editId = 1 + val removeId = 2 + popup.menu.add(0, editId, 0, R.string.edit_poll) + popup.menu.add(0, removeId, 0, R.string.action_remove) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + editId -> openPollDialog() + removeId -> removePoll() + } + true + } + popup.show() + } + } + + + private fun removePoll() { + viewModel.poll.value = null + pollPreview.hide() + } + + override fun onVisibilityChanged(visibility: Status.Visibility) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + viewModel.statusVisibility.value = visibility + } + + @VisibleForTesting + fun calculateTextLength(): Int { + var offset = 0 + val urlSpans = composeEditField.urls + if (urlSpans != null) { + for (span in urlSpans) { + offset += max(0, span.url.length - MAXIMUM_URL_LENGTH) + } + } + var length = composeEditField.length() - offset + if (viewModel.showContentWarning.value!!) { + length += composeContentWarningField.length() + } + return length + } + + private fun updateVisibleCharactersLeft() { + composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", maximumTootCharacters - calculateTextLength()) + } + + private fun onContentWarningChanged() { + val showWarning = composeContentWarningBar.isGone + viewModel.showContentWarning.value = showWarning + updateVisibleCharactersLeft() + } + + private fun onSendClicked() { + enableButtons(false) + sendStatus() + } + + /** This is for the fancy keyboards which can insert images and stuff. */ + override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle): Boolean { + try { + currentInputContentInfo?.releasePermission() + } catch (e: Exception) { + Log.e(TAG, "InputContentInfoCompat#releasePermission() failed." + e.message) + } finally { + currentInputContentInfo = null + } + + // Verify the returned content's type is of the correct MIME type + val supported = inputContentInfo.description.hasMimeType("image/*") + + return supported && onCommitContentInternal(inputContentInfo, flags) + } + + private fun onCommitContentInternal(inputContentInfo: InputContentInfoCompat, flags: Int): Boolean { + if (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION != 0) { + try { + inputContentInfo.requestPermission() + } catch (e: Exception) { + Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message) + return false + } + } + + // Determine the file size before putting handing it off to be put in the queue. + pickMedia(inputContentInfo.contentUri) + + currentInputContentInfo = inputContentInfo + currentFlags = flags + + return true + } + + private fun sendStatus() { + val contentText = composeEditField.text.toString() + var spoilerText = "" + if (viewModel.showContentWarning.value!!) { + spoilerText = composeContentWarningField.text.toString() + } + val characterCount = calculateTextLength() + if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) { + composeEditField.error = getString(R.string.error_empty) + enableButtons(true) + } else if (characterCount <= maximumTootCharacters) { + finishingUploadDialog = ProgressDialog.show( + this, getString(R.string.dialog_title_finishing_media_upload), + getString(R.string.dialog_message_uploading_media), true, true) + + viewModel.sendStatus(contentText, spoilerText).observe(this, Observer { + finishingUploadDialog?.dismiss() + finishWithoutSlideOutAnimation() + }) + + } else { + composeEditField.error = getString(R.string.error_compose_character_limit) + enableButtons(true) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, + grantResults: IntArray) { + if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + initiateMediaPicking() + } else { + val bar = Snackbar.make(activityCompose, R.string.error_media_upload_permission, + Snackbar.LENGTH_SHORT).apply { + + } + bar.setAction(R.string.action_retry) { onMediaPick()} + //necessary so snackbar is shown over everything + bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + bar.show() + } + } + } + + private fun initiateCameraApp() { + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + + // We don't need to ask for permission in this case, because the used calls require + // android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was + // way before permission dialogues have been introduced. + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + if (intent.resolveActivity(packageManager) != null) { + val photoFile: File = try { + createNewImageFile(this) + } catch (ex: IOException) { + displayTransientError(R.string.error_media_upload_opening) + return + } + + // Continue only if the File was successfully created + photoUploadUri = FileProvider.getUriForFile(this, + BuildConfig.APPLICATION_ID + ".fileprovider", + photoFile) + intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri) + startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT) + } + } + + private fun initiateMediaPicking() { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + + val mimeTypes = arrayOf("image/*", "video/*") + intent.type = "*/*" + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + startActivityForResult(intent, MEDIA_PICK_RESULT) + } + + private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { + button.isEnabled = clickable + ThemeUtils.setDrawableTint(this, button.drawable, + if (colorActive) android.R.attr.textColorTertiary + else R.attr.image_button_disabled_tint) + } + + private fun enablePollButton(enable: Boolean) { + addPollTextActionTextView.isEnabled = enable + val textColor = ThemeUtils.getColor(this, + if (enable) android.R.attr.textColorTertiary + else R.attr.image_button_disabled_tint) + addPollTextActionTextView.setTextColor(textColor) + addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) + } + + private fun removeMediaFromQueue(item: QueuedMedia) { + viewModel.removeMediaFromQueue(item) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { + pickMedia(intent.data!!) + } else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { + pickMedia(photoUploadUri!!) + } + } + + private fun pickMedia(uri: Uri) { + withLifecycleContext { + viewModel.pickMedia(uri).observe { exceptionOrItem -> + exceptionOrItem.asLeftOrNull()?.let { + val errorId = when (it) { + is VideoSizeException -> { + R.string.error_video_upload_size + } + is VideoOrImageException -> { + R.string.error_media_upload_image_or_video + } + else -> { + R.string.error_media_upload_opening + } + } + displayTransientError(errorId) + } + + } + } + } + + private fun showContentWarning(show: Boolean) { + TransitionManager.beginDelayedTransition(composeContentWarningBar.parent as ViewGroup) + @ColorInt val color = if (show) { + composeContentWarningBar.show() + composeContentWarningField.setSelection(composeContentWarningField.text.length) + composeContentWarningField.requestFocus() + ContextCompat.getColor(this, R.color.tusky_blue) + } else { + composeContentWarningBar.hide() + composeEditField.requestFocus() + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } + composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + handleCloseButton() + return true + } + + return super.onOptionsItemSelected(item) + } + + override fun onBackPressed() { + // Acting like a teen: deliberately ignoring parent. + if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + return + } + + handleCloseButton() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + Log.d(TAG, event.toString()) + if (event.isCtrlPressed) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + // send toot by pressing CTRL + ENTER + this.onSendClicked() + return true + } + } + + if (keyCode == KeyEvent.KEYCODE_BACK) { + onBackPressed() + return true + } + + return super.onKeyDown(keyCode, event) + } + + private fun handleCloseButton() { + val contentText = composeEditField.text.toString() + val contentWarning = composeContentWarningField.text.toString() + if (viewModel.didChange(contentText, contentWarning)) { + AlertDialog.Builder(this) + .setMessage(R.string.compose_save_draft) + .setPositiveButton(R.string.action_save) { _, _ -> + saveDraftAndFinish(contentText, contentWarning) + } + .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } + .show() + } else { + finishWithoutSlideOutAnimation() + } + } + + private fun deleteDraftAndFinish() { + viewModel.deleteDraft() + finishWithoutSlideOutAnimation() + } + + private fun saveDraftAndFinish(contentText: String, contentWarning: String) { + viewModel.saveDraft(contentText, contentWarning) + finishWithoutSlideOutAnimation() + } + + override fun search(token: String): List { + return viewModel.searchAutocompleteSuggestions(token) + } + + override fun onEmojiSelected(shortcode: String) { + replaceTextAtCaret(":$shortcode: ") + } + + private fun setEmojiList(emojiList: List?) { + if (emojiList != null) { + emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity) + enableButton(composeEmojiButton, true, emojiList.isNotEmpty()) + } + } + + data class QueuedMedia( + val localId: Long, + val uri: Uri, + val type: Type, + val mediaSize: Long, + val uploadPercent: Int = 0, + val id: String? = null, + val description: String? = null + ) { + enum class Type { + IMAGE, VIDEO; + } + } + + override fun onTimeSet(view: TimePicker, hourOfDay: Int, minute: Int) { + composeScheduleView.onTimeSet(hourOfDay, minute) + viewModel.updateScheduledAt(composeScheduleView.time) + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + private fun resetSchedule() { + viewModel.updateScheduledAt(null) + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + @Parcelize + data class ComposeOptions( + // Let's keep fields var until all consumers are Kotlin + var savedTootUid: Int? = null, + var tootText: String? = null, + var mediaUrls: List? = null, + var mediaDescriptions: List? = null, + var mentionedUsernames: Set? = null, + var inReplyToId: String? = null, + var replyVisibility: Status.Visibility? = null, + var visibility: Status.Visibility? = null, + var contentWarning: String? = null, + var replyingStatusAuthor: String? = null, + var replyingStatusContent: String? = null, + var mediaAttachments: List? = null, + var scheduledAt: String? = null, + var sensitive: Boolean? = null, + var poll: NewPoll? = null + ) : Parcelable + + companion object { + private const val TAG = "ComposeActivity" // logging tag + private const val MEDIA_PICK_RESULT = 1 + private const val MEDIA_TAKE_PHOTO_RESULT = 2 + private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 + + private const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" + + // Mastodon only counts URLs as this long in terms of status character limits + @VisibleForTesting + const val MAXIMUM_URL_LENGTH = 23 + + @JvmStatic + fun startIntent(context: Context, options: ComposeOptions): Intent { + return Intent(context, ComposeActivity::class.java).apply { + putExtra(COMPOSE_OPTIONS_EXTRA, options) + } + } + + @JvmStatic + fun canHandleMimeType(mimeType: String?): Boolean { + return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType == "text/plain") + } + } +} 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 new file mode 100644 index 000000000..05cf31b13 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -0,0 +1,467 @@ +/* Copyright 2019 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.net.Uri +import android.util.Log +import androidx.core.net.toUri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.service.TootToSend +import com.keylesspalace.tusky.util.* +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.rxkotlin.Singles +import java.util.* +import javax.inject.Inject + +open class RxAwareViewModel : ViewModel() { + private val disposables = CompositeDisposable() + + fun Disposable.autoDispose() = disposables.add(this) + + override fun onCleared() { + super.onCleared() + disposables.clear() + } +} + +/** + * Throw when trying to add an image when video is already present or the other way around + */ +class VideoOrImageException : Exception() + + +class ComposeViewModel +@Inject constructor( + private val api: MastodonApi, + private val accountManager: AccountManager, + private val mediaUploader: MediaUploader, + private val serviceClient: ServiceClient, + private val saveTootHelper: SaveTootHelper, + private val db: AppDatabase +) : RxAwareViewModel() { + + private var replyingStatusAuthor: String? = null + private var replyingStatusContent: String? = null + internal var startingText: String? = null + private var savedTootUid: Int = 0 + private var startingContentWarning: String? = null + private var inReplyToId: String? = null + private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN + + private val instance: MutableLiveData = MutableLiveData() + + val instanceParams: LiveData = instance.map { instance -> + ComposeInstanceParams( + maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, + pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, + pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false + ) + } + val emoji: MutableLiveData?> = MutableLiveData() + val markMediaAsSensitive = + mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + + fun toggleMarkSensitive() { + this.markMediaAsSensitive.value = !this.markMediaAsSensitive.value!! + } + + val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN) + val showContentWarning = mutableLiveData(false) + val poll: MutableLiveData = mutableLiveData(null) + val scheduledAt: MutableLiveData = mutableLiveData(null) + + val media = mutableLiveData>(listOf()) + val uploadError = MutableLiveData() + + private val mediaToDisposable = mutableMapOf() + + + init { + + Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance -> + InstanceEntity( + instance = accountManager.activeAccount?.domain!!, + emojiList = emojis, + maximumTootCharacters = instance.maxTootChars, + maxPollOptions = instance.pollLimits?.maxOptions, + maxPollOptionLength = instance.pollLimits?.maxOptionChars, + version = instance.version + ) + } + .doOnSuccess { + db.instanceDao().insertOrReplace(it) + } + .onErrorResumeNext( + db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) + ) + .subscribe ({ instanceEntity -> + emoji.postValue(instanceEntity.emojiList) + instance.postValue(instanceEntity) + }, { throwable -> + // this can happen on network error when no cached data is available + Log.w(TAG, "error loading instance data", throwable) + }) + .autoDispose() + } + + fun pickMedia(uri: Uri): LiveData> { + // We are not calling .toLiveData() here because we don't want to stop the process when + // the Activity goes away temporarily (like on screen rotation). + val liveData = MutableLiveData>() + mediaUploader.prepareMedia(uri) + .map { (type, uri, size) -> + val mediaItems = media.value!! + if (type == QueuedMedia.Type.VIDEO + && mediaItems.isNotEmpty() + && mediaItems[0].type == QueuedMedia.Type.IMAGE) { + throw VideoOrImageException() + } else { + addMediaToQueue(type, uri, size) + } + } + .subscribe({ queuedMedia -> + liveData.postValue(Either.Right(queuedMedia)) + }, { error -> + liveData.postValue(Either.Left(error)) + }) + .autoDispose() + return liveData + } + + private fun addMediaToQueue(type: QueuedMedia.Type, uri: Uri, mediaSize: Long): QueuedMedia { + val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize) + media.value = media.value!! + mediaItem + mediaToDisposable[mediaItem.localId] = mediaUploader + .uploadMedia(mediaItem) + .subscribe ({ event -> + val item = media.value?.find { it.localId == mediaItem.localId } + ?: return@subscribe + val newMediaItem = when (event) { + is UploadEvent.ProgressEvent -> + item.copy(uploadPercent = event.percentage) + is UploadEvent.FinishedEvent -> + item.copy(id = event.attachment.id, uploadPercent = -1) + } + synchronized(media) { + val mediaValue = media.value!! + val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId } + media.postValue(if (index == -1) { + mediaValue + newMediaItem + } else { + mediaValue.toMutableList().also { it[index] = newMediaItem } + }) + } + }, { error -> + media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList()) + uploadError.postValue(error) + }) + return mediaItem + } + + private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) { + val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, -1, id, description) + media.value = media.value!! + mediaItem + } + + fun removeMediaFromQueue(item: QueuedMedia) { + mediaToDisposable[item.localId]?.dispose() + media.value = media.value!!.withoutFirstWhich { it.localId == item.localId } + } + + fun didChange(content: String?, contentWarning: String?): Boolean { + + val textChanged = !(content.isNullOrEmpty() + || startingText?.startsWith(content.toString()) ?: false) + + val contentWarningChanged = showContentWarning.value!! + && !contentWarning.isNullOrEmpty() + && !startingContentWarning!!.startsWith(contentWarning.toString()) + val mediaChanged = media.value!!.isNotEmpty() + val pollChanged = poll.value != null + + return textChanged || contentWarningChanged || mediaChanged || pollChanged + } + + fun deleteDraft() { + saveTootHelper.deleteDraft(this.savedTootUid) + } + + fun saveDraft(content: String, contentWarning: String) { + val mediaUris = mutableListOf() + val mediaDescriptions = mutableListOf() + for (item in media.value!!) { + mediaUris.add(item.uri.toString()) + mediaDescriptions.add(item.description) + } + saveTootHelper.saveToot( + content, + contentWarning, + null, + mediaUris, + mediaDescriptions, + savedTootUid, + inReplyToId, + replyingStatusContent, + replyingStatusAuthor, + statusVisibility.value!!, + poll.value + ) + } + + /** + * Send status to the server. + * Uses current state plus provided arguments. + * @return LiveData which will signal once the screen can be closed or null if there are errors + */ + fun sendStatus( + content: String, + spoilerText: String + ): LiveData { + return media + .filter { items -> items.all { it.uploadPercent == -1 } } + .map { + val mediaIds = ArrayList() + val mediaUris = ArrayList() + val mediaDescriptions = ArrayList() + for (item in media.value!!) { + mediaIds.add(item.id!!) + mediaUris.add(item.uri) + mediaDescriptions.add(item.description ?: "") + } + + val tootToSend = TootToSend( + content, + spoilerText, + statusVisibility.value!!.serverString(), + mediaUris.isNotEmpty() && markMediaAsSensitive.value!!, + mediaIds, + mediaUris.map { it.toString() }, + mediaDescriptions, + scheduledAt = scheduledAt.value, + inReplyToId = null, + poll = poll.value, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + savedJsonUrls = null, + accountId = accountManager.activeAccount!!.id, + savedTootUid = 0, + idempotencyKey = randomAlphanumericString(16), + retries = 0 + ) + serviceClient.sendToot(tootToSend) + } + } + + fun updateDescription(localId: Long, description: String): LiveData { + val newList = media.value!!.toMutableList() + val index = newList.indexOfFirst { it.localId == localId } + if (index != -1) { + newList[index] = newList[index].copy(description = description) + } + media.value = newList + val completedCaptioningLiveData = MutableLiveData() + media.observeForever(object : Observer> { + override fun onChanged(mediaItems: List) { + val updatedItem = mediaItems.find { it.localId == localId } + if (updatedItem == null) { + media.removeObserver(this) + } else if (updatedItem.id != null) { + api.updateMedia(updatedItem.id, description) + .subscribe({ + completedCaptioningLiveData.postValue(true) + }, { + completedCaptioningLiveData.postValue(false) + }) + .autoDispose() + media.removeObserver(this) + } + } + }) + return completedCaptioningLiveData + } + + + 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 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() + } + } + ':' -> { + val emojiList = emoji.value ?: return emptyList() + + val incomplete = token.substring(1).toLowerCase(Locale.ROOT) + val results = ArrayList() + val resultsInside = ArrayList() + for (emoji in emojiList) { + val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT) + if (shortcode.startsWith(incomplete)) { + results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) + } else if (shortcode.indexOf(incomplete, 1) != -1) { + resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) + } + } + if (results.isNotEmpty() && resultsInside.isNotEmpty()) { + results.add(ComposeAutoCompleteAdapter.ResultSeparator()) + } + results.addAll(resultsInside) + return results + } + else -> { + Log.w(TAG, "Unexpected autocompletion token: $token") + return emptyList() + } + } + } + + override fun onCleared() { + for (uploadDisposable in mediaToDisposable.values) { + uploadDisposable.dispose() + } + super.onCleared() + } + + fun setup(composeOptions: ComposeActivity.ComposeOptions?) { + val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy + + val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN + startingVisibility = Status.Visibility.byNum( + preferredVisibility.num.coerceAtLeast(replyVisibility.num)) + statusVisibility.value = startingVisibility + + inReplyToId = composeOptions?.inReplyToId + + + val contentWarning = composeOptions?.contentWarning + if (contentWarning != null) { + startingContentWarning = contentWarning + } + + // recreate media list + // when coming from SavedTootActivity + val loadedDraftMediaUris = composeOptions?.mediaUrls + val loadedDraftMediaDescriptions: List? = composeOptions?.mediaDescriptions + if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) { + loadedDraftMediaUris.zip(loadedDraftMediaDescriptions) + .forEach { (uri, description) -> + pickMedia(uri.toUri()).observeForever { errorOrItem -> + if (errorOrItem.isRight() && description != null) { + updateDescription(errorOrItem.asRight().localId, description) + } + } + } + } else composeOptions?.mediaAttachments?.forEach { a -> + // when coming from redraft + val mediaType = when (a.type) { + Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO + Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE + else -> QueuedMedia.Type.IMAGE + } + addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) + } + + + composeOptions?.savedTootUid?.let { uid -> + this.savedTootUid = uid + startingText = composeOptions.tootText + } + + val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN + if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { + startingVisibility = tootVisibility + } + val mentionedUsernames = composeOptions?.mentionedUsernames + if (mentionedUsernames != null) { + val builder = StringBuilder() + for (name in mentionedUsernames) { + builder.append('@') + builder.append(name) + builder.append(' ') + } + startingText = builder.toString() + } + + + scheduledAt.value = composeOptions?.scheduledAt + + composeOptions?.sensitive?.let { markMediaAsSensitive.value = it } + + val poll = composeOptions?.poll + if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) { + this.poll.value = poll + } + replyingStatusContent = composeOptions?.replyingStatusContent + replyingStatusAuthor = composeOptions?.replyingStatusAuthor + } + + fun updatePoll(newPoll: NewPoll) { + poll.value = newPoll + } + + fun updateScheduledAt(newScheduledAt: String?) { + scheduledAt.value = newScheduledAt + } + + private companion object { + const val TAG = "ComposeViewModel" + } + +} + +fun mutableLiveData(default: T) = MutableLiveData().apply { value = default } + +const val DEFAULT_CHARACTER_LIMIT = 500 +private const val DEFAULT_MAX_OPTION_COUNT = 4 +private const val DEFAULT_MAX_OPTION_LENGTH = 25 + +data class ComposeInstanceParams( + val maxChars: Int, + val pollMaxOptions: Int, + val pollMaxLength: Int, + val supportsScheduled: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java similarity index 88% rename from app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java rename to app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java index 364f0e849..880a41679 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java @@ -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.content.ContentResolver; import android.graphics.Bitmap; @@ -21,6 +21,8 @@ import android.graphics.BitmapFactory; import android.net.Uri; import android.os.AsyncTask; +import com.keylesspalace.tusky.util.IOUtils; + import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; @@ -42,10 +44,10 @@ public class DownsizeImageTask extends AsyncTask { private File tempFile; /** - * @param sizeLimit the maximum number of bytes each image can take + * @param sizeLimit the maximum number of bytes each image can take * @param contentResolver to resolve the specified images' URIs - * @param tempFile the file where the result will be stored - * @param listener to whom the results are given + * @param tempFile the file where the result will be stored + * @param listener to whom the results are given */ public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) { this.sizeLimit = sizeLimit; @@ -56,6 +58,25 @@ public class DownsizeImageTask extends AsyncTask { @Override protected Boolean doInBackground(Uri... uris) { + boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile); + if (isCancelled()) { + return false; + } + return result; + } + + @Override + protected void onPostExecute(Boolean successful) { + if (successful) { + listener.onSuccess(tempFile); + } else { + listener.onFailure(); + } + super.onPostExecute(successful); + } + + public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver, + File tempFile) { for (Uri uri : uris) { InputStream inputStream; try { @@ -118,27 +139,16 @@ public class DownsizeImageTask extends AsyncTask { reorientedBitmap.recycle(); scaledImageSize /= 2; } while (tempFile.length() > sizeLimit); - - if (isCancelled()) { - return false; - } } return true; } - @Override - protected void onPostExecute(Boolean successful) { - if (successful) { - listener.onSuccess(tempFile); - } else { - listener.onFailure(); - } - super.onPostExecute(successful); - } - - /** Used to communicate the results of the task. */ + /** + * Used to communicate the results of the task. + */ public interface Listener { void onSuccess(File file); + void onFailure(); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt new file mode 100644 index 000000000..babb0a391 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -0,0 +1,105 @@ +/* Copyright 2019 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.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.PopupMenu +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.view.ProgressImageView + +class MediaPreviewAdapter( + context: Context, + private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, + private val onRemove: (ComposeActivity.QueuedMedia) -> Unit +) : RecyclerView.Adapter() { + + fun submitList(list: List) { + this.differ.submitList(list) + } + + private fun onMediaClick(position: Int, view: View) { + val item = differ.currentList[position] + val popup = PopupMenu(view.context, view) + val addCaptionId = 1 + val removeId = 2 + popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + popup.menu.add(0, removeId, 0, R.string.action_remove) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + addCaptionId -> onAddCaption(item) + removeId -> onRemove(item) + } + true + } + popup.show() + } + + private val thumbnailViewSize = + context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) + + override fun getItemCount(): Int = differ.currentList.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder { + return PreviewViewHolder(ProgressImageView(parent.context)) + } + + override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) { + val item = differ.currentList[position] + holder.progressImageView.setChecked(!item.description.isNullOrEmpty()) + holder.progressImageView.setProgress(item.uploadPercent) + Glide.with(holder.itemView.context) + .load(item.uri) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .into(holder.progressImageView) + } + + private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { + return oldItem.localId == newItem.localId + } + + override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { + return oldItem == newItem + } + }) + + inner class PreviewViewHolder(val progressImageView: ProgressImageView) + : RecyclerView.ViewHolder(progressImageView) { + init { + val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) + val margin = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin) + val marginBottom = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + layoutParams.setMargins(margin, 0, margin, marginBottom) + progressImageView.layoutParams = layoutParams + progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP + progressImageView.setOnClickListener { + onMediaClick(adapterPosition, progressImageView) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt new file mode 100644 index 000000000..af41f4bbd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -0,0 +1,203 @@ +/* Copyright 2019 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.content.Context +import android.net.Uri +import android.os.Environment +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.ProgressRequestBody +import com.keylesspalace.tusky.util.* +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.* + +sealed class UploadEvent { + data class ProgressEvent(val percentage: Int) : UploadEvent() + data class FinishedEvent(val attachment: Attachment) : UploadEvent() +} + +fun createNewImageFile(context: Context): File { + // Create an image file name + val randomId = randomAlphanumericString(12) + val imageFileName = "Tusky_${randomId}_" + val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File.createTempFile( + imageFileName, /* prefix */ + ".jpg", /* suffix */ + storageDir /* directory */ + ) +} + +data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long) + +interface MediaUploader { + fun prepareMedia(inUri: Uri): Single + fun uploadMedia(media: QueuedMedia): Observable +} + +class VideoSizeException : Exception() +class MediaTypeException : Exception() +class CouldNotOpenFileException : Exception() + +class MediaUploaderImpl( + private val context: Context, + private val mastodonApi: MastodonApi +) : MediaUploader { + override fun uploadMedia(media: QueuedMedia): Observable { + return Observable + .fromCallable { + if (shouldResizeMedia(media)) { + downsize(media) + } + media + } + .switchMap { upload(it) } + .subscribeOn(Schedulers.io()) + } + + override fun prepareMedia(inUri: Uri): Single { + return Single.fromCallable { + var mediaSize = getMediaSize(contentResolver, inUri) + var uri = inUri + val mimeType = contentResolver.getType(uri) + + val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") + + try { + contentResolver.openInputStream(inUri).use { input -> + if (input == null) { + Log.w(TAG, "Media input is null") + uri = inUri + return@use + } + val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) + FileOutputStream(file.absoluteFile).use { out -> + input.copyTo(out) + uri = FileProvider.getUriForFile(context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file) + mediaSize = getMediaSize(contentResolver, uri) + } + + } + } catch (e: IOException) { + Log.w(TAG, e) + uri = inUri + } + if (mediaSize == MEDIA_SIZE_UNKNOWN) { + throw CouldNotOpenFileException() + } + + if (mimeType != null) { + val topLevelType = mimeType.substring(0, mimeType.indexOf('/')) + when (topLevelType) { + "video" -> { + if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { + throw VideoSizeException() + } + PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) + } + "image" -> { + PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize) + } + else -> { + throw MediaTypeException() + } + } + } else { + throw MediaTypeException() + } + } + } + + private val contentResolver = context.contentResolver + + private fun upload(media: QueuedMedia): Observable { + return Observable.create { emitter -> + var mimeType = contentResolver.getType(media.uri) + val map = MimeTypeMap.getSingleton() + val fileExtension = map.getExtensionFromMimeType(mimeType) + val filename = String.format("%s_%s_%s.%s", + context.getString(R.string.app_name), + Date().time.toString(), + randomAlphanumericString(10), + fileExtension) + + val stream = contentResolver.openInputStream(media.uri) + + if (mimeType == null) mimeType = "multipart/form-data" + + + var lastProgress = -1 + val fileBody = ProgressRequestBody(stream, media.mediaSize, + mimeType.toMediaTypeOrNull()) { percentage -> + if (percentage != lastProgress) { + emitter.onNext(UploadEvent.ProgressEvent(percentage)) + } + lastProgress = percentage + } + + val body = MultipartBody.Part.createFormData("file", filename, fileBody) + + val uploadDisposable = mastodonApi.uploadMedia(body) + .subscribe({ attachment -> + emitter.onNext(UploadEvent.FinishedEvent(attachment)) + emitter.onComplete() + }, { e -> + emitter.onError(e) + }) + + // Cancel the request when our observable is cancelled + emitter.setDisposable(uploadDisposable) + } + } + + private fun downsize(media: QueuedMedia): QueuedMedia { + val file = createNewImageFile(context) + DownsizeImageTask.resize(arrayOf(media.uri), + STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file) + return media.copy(uri = file.toUri(), mediaSize = file.length()) + } + + private fun shouldResizeMedia(media: QueuedMedia): Boolean { + return media.type == QueuedMedia.Type.IMAGE + && (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT + || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT) + } + + private companion object { + private const val TAG = "MediaUploaderImpl" + private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB + private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB + private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels + + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt similarity index 69% rename from app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt index 3ed211b2a..d0f98bac6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -15,29 +15,28 @@ @file:JvmName("AddPollDialog") -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.compose.dialog +import android.content.Context +import android.view.LayoutInflater +import android.view.WindowManager import androidx.appcompat.app.AlertDialog -import com.keylesspalace.tusky.ComposeActivity +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.AddPollOptionsAdapter import com.keylesspalace.tusky.entity.NewPoll import kotlinx.android.synthetic.main.dialog_add_poll.view.* -import android.view.WindowManager -import com.keylesspalace.tusky.R - -private const val DEFAULT_MAX_OPTION_COUNT = 4 -private const val DEFAULT_MAX_OPTION_LENGTH = 25 fun showAddPollDialog( - activity: ComposeActivity, + context: Context, poll: NewPoll?, - maxOptionCount: Int?, - maxOptionLength: Int? + maxOptionCount: Int, + maxOptionLength: Int, + onUpdatePoll: (NewPoll) -> Unit ) { - val view = activity.layoutInflater.inflate(R.layout.dialog_add_poll, null) + val view = LayoutInflater.from(context).inflate(R.layout.dialog_add_poll, null) - val dialog = AlertDialog.Builder(activity) + val dialog = AlertDialog.Builder(context) .setIcon(R.drawable.ic_poll_24dp) .setTitle(R.string.create_poll_title) .setView(view) @@ -47,7 +46,7 @@ fun showAddPollDialog( val adapter = AddPollOptionsAdapter( options = poll?.options?.toMutableList() ?: mutableListOf("", ""), - maxOptionLength = maxOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + maxOptionLength = maxOptionLength, onOptionRemoved = { valid -> view.addChoiceButton.isEnabled = true dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid @@ -60,15 +59,15 @@ fun showAddPollDialog( view.pollChoices.adapter = adapter view.addChoiceButton.setOnClickListener { - if (adapter.itemCount < maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) { + if (adapter.itemCount < maxOptionCount) { adapter.addChoice() } - if (adapter.itemCount >= maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) { + if (adapter.itemCount >= maxOptionCount) { it.isEnabled = false } } - val pollDurationId = activity.resources.getIntArray(R.array.poll_duration_values).indexOfLast { + val pollDurationId = context.resources.getIntArray(R.array.poll_duration_values).indexOfLast { it <= poll?.expiresIn ?: 0 } @@ -81,15 +80,14 @@ fun showAddPollDialog( button.setOnClickListener { val selectedPollDurationId = view.pollDurationSpinner.selectedItemPosition - val pollDuration = activity.resources.getIntArray(R.array.poll_duration_values)[selectedPollDurationId] + val pollDuration = context.resources + .getIntArray(R.array.poll_duration_values)[selectedPollDurationId] - activity.updatePoll( - NewPoll( - options = adapter.pollOptions, - expiresIn = pollDuration, - multiple = view.multipleChoicesCheckBox.isChecked - ) - ) + onUpdatePoll(NewPoll( + options = adapter.pollOptions, + expiresIn = pollDuration, + multiple = view.multipleChoicesCheckBox.isChecked + )) dialog.dismiss() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt new file mode 100644 index 000000000..e7cc36cbe --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -0,0 +1,113 @@ +/* Copyright 2019 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.dialog + +import android.app.Activity +import android.content.DialogInterface +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.InputFilter +import android.text.InputType +import android.util.DisplayMetrics +import android.view.WindowManager +import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import at.connyduck.sparkbutton.helpers.Utils +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.withLifecycleContext + +// https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94 +private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420 + + +fun T.makeCaptionDialog(existingDescription: String?, + previewUri: Uri, + onUpdateDescription: (String) -> LiveData +) where T : Activity, T : LifecycleOwner { + val dialogLayout = LinearLayout(this) + val padding = Utils.dpToPx(this, 8) + dialogLayout.setPadding(padding, padding, padding, padding) + + dialogLayout.orientation = LinearLayout.VERTICAL + val imageView = ImageView(this) + + val displayMetrics = DisplayMetrics() + windowManager.defaultDisplay.getMetrics(displayMetrics) + + val margin = Utils.dpToPx(this, 4) + dialogLayout.addView(imageView) + (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f + imageView.layoutParams.height = 0 + (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) + + val input = EditText(this) + input.hint = getString(R.string.hint_describe_for_visually_impaired, + MEDIA_DESCRIPTION_CHARACTER_LIMIT) + dialogLayout.addView(input) + (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) + input.setLines(2) + input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + input.setText(existingDescription) + input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) + + val okListener = { dialog: DialogInterface, _: Int -> + onUpdateDescription(input.text.toString()) + withLifecycleContext { + onUpdateDescription(input.text.toString()) + .observe { success -> if (!success) showFailedCaptionMessage() } + + } + + dialog.dismiss() + } + + val dialog = AlertDialog.Builder(this) + .setView(dialogLayout) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, null) + .create() + + val window = dialog.window + window?.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + dialog.show() + + // Load the image and manually set it into the ImageView because it doesn't have a fixed + // size. Maybe we should limit the size of CustomTarget + Glide.with(this) + .load(previewUri) + .into(object : CustomTarget() { + override fun onLoadCleared(placeholder: Drawable?) {} + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + imageView.setImageDrawable(resource) + } + }) +} + + +private fun Activity.showFailedCaptionMessage() { + Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt index 73bc21a23..c99df40f2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.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.view +package com.keylesspalace.tusky.components.compose.view import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java similarity index 95% rename from app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java index dc58f86ec..af10b2776 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java @@ -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.view; +package com.keylesspalace.tusky.components.compose.view; import android.content.Context; import android.graphics.drawable.Drawable; @@ -30,6 +30,7 @@ import com.google.android.material.datepicker.DateValidatorPointForward; import com.google.android.material.datepicker.MaterialDatePicker; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.fragment.TimePickerFragment; +import com.keylesspalace.tusky.util.ThemeUtils; import java.text.DateFormat; import java.text.ParseException; @@ -87,7 +88,7 @@ public class ComposeScheduleView extends ConstraintLayout { private void setScheduledDateTime() { if (scheduleDateTime == null) { - scheduledDateTimeView.setText(R.string.hint_configure_scheduled_toot); + scheduledDateTimeView.setText(""); } else { scheduledDateTimeView.setText(String.format("%s %s", dateFormat.format(scheduleDateTime.getTime()), @@ -96,13 +97,13 @@ public class ComposeScheduleView extends ConstraintLayout { } private void setEditIcons() { - final int size = scheduledDateTimeView.getLineHeight(); - - Drawable icon = getContext().getDrawable(R.drawable.ic_create_24dp); + Drawable icon = ThemeUtils.getTintedDrawable(getContext(), R.drawable.ic_create_24dp, android.R.attr.textColorTertiary); if (icon == null) { return; } + final int size = scheduledDateTimeView.getLineHeight(); + icon.setBounds(0, 0, size, size); scheduledDateTimeView.setCompoundDrawables(null, null, icon, null); @@ -117,7 +118,7 @@ public class ComposeScheduleView extends ConstraintLayout { setScheduledDateTime(); } - private void openPickDateDialog() { + public void openPickDateDialog() { long yesterday = Calendar.getInstance().getTimeInMillis() - 24 * 60 * 60 * 1000; CalendarConstraints calendarConstraints = new CalendarConstraints.Builder() .setValidator( diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt index 1ee7e84af..0a5e1c33a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.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.view +package com.keylesspalace.tusky.components.compose.view import android.content.Context import androidx.emoji.widget.EmojiEditTextHelper diff --git a/app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt index e82831fd2..63e627fc1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.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.view +package com.keylesspalace.tusky.components.compose.view import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java index bfb474eec..836d81bd0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java @@ -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.view; +package com.keylesspalace.tusky.components.compose.view; import android.content.Context; import android.graphics.Canvas; diff --git a/app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt index 333f41c9c..c641f345c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.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.view +package com.keylesspalace.tusky.components.compose.view import android.content.Context import android.graphics.Color diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 5a4670ca1..02d02c9c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -38,7 +38,12 @@ import androidx.paging.PagedListAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter import com.keylesspalace.tusky.db.AccountEntity @@ -195,14 +200,14 @@ class SearchStatusesFragment : SearchFragment + .subscribe({ deletedStatus -> removeItem(position) - val redraftStatus = if(deletedStatus.isEmpty()) { + val redraftStatus = if (deletedStatus.isEmpty()) { status.toDeletedStatus() } else { deletedStatus } - val intent = ComposeActivity.IntentBuilder() - .tootText(redraftStatus.text) - .inReplyToId(redraftStatus.inReplyToId) - .visibility(redraftStatus.visibility) - .contentWarning(redraftStatus.spoilerText) - .mediaAttachments(redraftStatus.attachments) - .sensitive(redraftStatus.sensitive) - .poll(redraftStatus.poll?.toNewPoll(status.createdAt)) - .build(context) + val intent = ComposeActivity.startIntent(context!!, ComposeOptions( + tootText = redraftStatus.text ?: "", + inReplyToId = redraftStatus.inReplyToId, + visibility = redraftStatus.visibility, + contentWarning = redraftStatus.spoilerText, + mediaAttachments = redraftStatus.attachments, + sensitive = redraftStatus.sensitive, + poll = redraftStatus.poll?.toNewPoll(status.createdAt) + )) startActivity(intent) }, { error -> Log.w("SearchStatusesFragment", "error deleting status", error) 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 d37df70a2..e4d743c98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -30,7 +30,7 @@ import androidx.annotation.NonNull; @Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 20) + }, version = 21) public abstract class AppDatabase extends RoomDatabase { public abstract TootDao tootDao(); @@ -316,6 +316,14 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `bookmarked` INTEGER NOT NULL DEFAULT 0"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_bookmarked` INTEGER NOT NULL DEFAULT 0"); } + + }; + + public static final Migration MIGRATION_20_21 = new Migration(20, 21) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `version` TEXT"); + } }; } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt index bc87d018f..0c78349ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -19,6 +19,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import io.reactivex.Single @Dao interface InstanceDao { @@ -26,5 +27,5 @@ interface InstanceDao { fun insertOrReplace(instance: InstanceEntity) @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") - fun loadMetadataForInstance(instance: String): InstanceEntity? + fun loadMetadataForInstance(instance: String): Single } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index 0797ffb6a..1e2adaf04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -27,5 +27,6 @@ data class InstanceEntity( val emojiList: List?, val maximumTootCharacters: Int?, val maxPollOptions: Int?, - val maxPollOptionLength: Int? + val maxPollOptionLength: Int?, + val version: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 75d6b446b..e2c4dfc73 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.SearchActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index 996ad5059..ff3d02669 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -35,7 +35,8 @@ import javax.inject.Singleton ServicesModule::class, BroadcastReceiverModule::class, ViewModelModule::class, - RepositoryModule::class + RepositoryModule::class, + MediaUploaderModule::class ]) interface AppComponent { @Component.Builder diff --git a/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt new file mode 100644 index 000000000..66dc27110 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt @@ -0,0 +1,30 @@ +/* Copyright 2019 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.di + +import android.content.Context +import com.keylesspalace.tusky.components.compose.MediaUploader +import com.keylesspalace.tusky.components.compose.MediaUploaderImpl +import com.keylesspalace.tusky.network.MastodonApi +import dagger.Module +import dagger.Provides + +@Module +class MediaUploaderModule { + @Provides + fun providesMediaUploder(context: Context, mastodonApi: MastodonApi): MediaUploader = + MediaUploaderImpl(context, mastodonApi) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt index 9015b5f21..5f6495543 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt @@ -15,12 +15,25 @@ package com.keylesspalace.tusky.di +import android.content.Context import com.keylesspalace.tusky.service.SendTootService +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.service.ServiceClientImpl import dagger.Module +import dagger.Provides import dagger.android.ContributesAndroidInjector @Module abstract class ServicesModule { @ContributesAndroidInjector abstract fun contributesSendTootService(): SendTootService + + @Module + companion object { + @Provides + @JvmStatic + fun providesServiceClient(context: Context): ServiceClient { + return ServiceClientImpl(context) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 3706bc11d..8381d526a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -4,10 +4,13 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.search.SearchViewModel -import com.keylesspalace.tusky.viewmodel.* +import com.keylesspalace.tusky.viewmodel.AccountViewModel +import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel +import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel import dagger.Binds import dagger.MapKey @@ -71,5 +74,10 @@ abstract class ViewModelModule { @ViewModelKey(SearchViewModel::class) internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ComposeViewModel::class) + internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel + //Add more ViewModels here } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 0a4246012..6e7164389 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -42,12 +42,13 @@ import androidx.lifecycle.Lifecycle; import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.BottomSheetActivity; -import com.keylesspalace.tusky.ComposeActivity; import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.PostLookupFallbackBehavior; import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.ViewTagActivity; +import com.keylesspalace.tusky.components.compose.ComposeActivity; +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; import com.keylesspalace.tusky.components.report.ReportActivity; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; @@ -148,21 +149,22 @@ public abstract class SFragment extends BaseFragment implements Injectable { mentionedUsernames.add(actionableStatus.getAccount().getUsername()); String loggedInUsername = null; AccountEntity activeAccount = accountManager.getActiveAccount(); - if(activeAccount != null) { + if (activeAccount != null) { loggedInUsername = activeAccount.getUsername(); } for (Status.Mention mention : mentions) { mentionedUsernames.add(mention.getUsername()); } mentionedUsernames.remove(loggedInUsername); - Intent intent = new ComposeActivity.IntentBuilder() - .inReplyToId(inReplyToId) - .replyVisibility(replyVisibility) - .contentWarning(contentWarning) - .mentionedUsernames(mentionedUsernames) - .replyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()) - .replyingStatusContent(actionableStatus.getContent().toString()) - .build(getContext()); + ComposeOptions composeOptions = new ComposeOptions(); + composeOptions.setInReplyToId(inReplyToId); + composeOptions.setReplyVisibility(replyVisibility); + composeOptions.setContentWarning(contentWarning); + composeOptions.setMentionedUsernames(mentionedUsernames); + composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()); + composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString()); + + Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); getActivity().startActivity(intent); } @@ -176,7 +178,7 @@ public abstract class SFragment extends BaseFragment implements Injectable { String loggedInAccountId = null; AccountEntity activeAccount = accountManager.getActiveAccount(); - if(activeAccount != null) { + if (activeAccount != null) { loggedInAccountId = activeAccount.getAccountId(); } @@ -209,7 +211,7 @@ public abstract class SFragment extends BaseFragment implements Injectable { Menu menu = popup.getMenu(); MenuItem openAsItem = menu.findItem(R.id.status_open_as); - switch(accounts.size()) { + switch (accounts.size()) { case 0: case 1: openAsItem.setVisible(false); @@ -232,7 +234,8 @@ public abstract class SFragment extends BaseFragment implements Injectable { switch (item.getItemId()) { case R.id.status_share_content: { Status statusToShare = status; - if(statusToShare.getReblog() != null) statusToShare = statusToShare.getReblog(); + if (statusToShare.getReblog() != null) + statusToShare = statusToShare.getReblog(); Intent sendIntent = new Intent(); sendIntent.setAction(Intent.ACTION_SEND); @@ -357,7 +360,8 @@ public abstract class SFragment extends BaseFragment implements Injectable { .observeOn(AndroidSchedulers.mainThread()) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( - deletedStatus -> {}, + deletedStatus -> { + }, error -> { Log.w("SFragment", "error deleting status", error); Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show(); @@ -381,22 +385,22 @@ public abstract class SFragment extends BaseFragment implements Injectable { .subscribe(deletedStatus -> { removeItem(position); - if(deletedStatus.isEmpty()) { + if (deletedStatus.isEmpty()) { deletedStatus = status.toDeletedStatus(); } - - ComposeActivity.IntentBuilder intentBuilder = new ComposeActivity.IntentBuilder() - .tootText(deletedStatus.getText()) - .inReplyToId(deletedStatus.getInReplyToId()) - .visibility(deletedStatus.getVisibility()) - .contentWarning(deletedStatus.getSpoilerText()) - .mediaAttachments(deletedStatus.getAttachments()) - .sensitive(deletedStatus.getSensitive()); - if(deletedStatus.getPoll() != null) { - intentBuilder.poll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt())); + ComposeOptions composeOptions = new ComposeOptions(); + composeOptions.setTootText(deletedStatus.getText()); + composeOptions.setInReplyToId(deletedStatus.getInReplyToId()); + composeOptions.setVisibility(deletedStatus.getVisibility()); + composeOptions.setContentWarning(deletedStatus.getSpoilerText()); + composeOptions.setMediaAttachments(deletedStatus.getAttachments()); + composeOptions.setSensitive(deletedStatus.getSensitive()); + if (deletedStatus.getPoll() != null) { + composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt())); } - Intent intent = intentBuilder.build(getContext()); + Intent intent = ComposeActivity + .startIntent(getContext(), composeOptions); startActivity(intent); }, error -> { @@ -415,22 +419,22 @@ public abstract class SFragment extends BaseFragment implements Injectable { intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); intent.putExtra(MainActivity.STATUS_URL, statusUrl); startActivity(intent); - ((BaseActivity)getActivity()).finishWithoutSlideOutAnimation(); + ((BaseActivity) getActivity()).finishWithoutSlideOutAnimation(); } private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) { - BaseActivity activity = (BaseActivity)getActivity(); + BaseActivity activity = (BaseActivity) getActivity(); activity.showAccountChooserDialog(dialogTitle, false, account -> openAsAccount(statusUrl, account)); } private void downloadAllMedia(Status status) { Toast.makeText(getContext(), R.string.downloading_media, Toast.LENGTH_SHORT).show(); - for(Attachment attachment: status.getAttachments()) { + for (Attachment attachment : status.getAttachments()) { String url = attachment.getUrl(); Uri uri = Uri.parse(url); String filename = uri.getLastPathSegment(); - DownloadManager downloadManager = (DownloadManager)getActivity().getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager downloadManager = (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE); DownloadManager.Request request = new DownloadManager.Request(uri); request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename); downloadManager.enqueue(request); @@ -438,8 +442,8 @@ public abstract class SFragment extends BaseFragment implements Injectable { } private void requestDownloadAllMedia(Status status) { - String[] permissions = new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE }; - ((BaseActivity)getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { + String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + ((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { downloadAllMedia(status); } else { @@ -487,9 +491,9 @@ public abstract class SFragment extends BaseFragment implements Injectable { @VisibleForTesting public boolean shouldFilterStatus(Status status) { - if(filterRemoveRegex && status.getPoll() != null) { - for(PollOption option: status.getPoll().getOptions()) { - if(filterRemoveRegexMatcher.reset(option.getTitle()).find()) { + if (filterRemoveRegex && status.getPoll() != null) { + for (PollOption option : status.getPoll().getOptions()) { + if (filterRemoveRegexMatcher.reset(option.getTitle()).find()) { return true; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java index e4b20dda5..1349a59c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java @@ -22,7 +22,7 @@ import android.os.Bundle; import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; -import com.keylesspalace.tusky.ComposeActivity; +import com.keylesspalace.tusky.components.compose.ComposeActivity; import java.util.Calendar; import java.util.TimeZone; 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 fca1776bf..2fb3f9408 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -43,7 +43,7 @@ interface MastodonApi { fun getLists(): Single> @GET("/api/v1/custom_emojis") - fun getCustomEmojis(): Call> + fun getCustomEmojis(): Single> @GET("api/v1/instance") fun getInstance(): Single @@ -116,14 +116,14 @@ interface MastodonApi { @POST("api/v1/media") fun uploadMedia( @Part file: MultipartBody.Part - ): Call + ): Single @FormUrlEncoded @PUT("api/v1/media/{mediaId}") fun updateMedia( @Path("mediaId") mediaId: String, @Field("description") description: String - ): Call + ): Single @POST("api/v1/statuses") fun createStatus( @@ -238,10 +238,10 @@ interface MastodonApi { @GET("api/v1/accounts/search") fun searchAccounts( - @Query("q") q: String, - @Query("resolve") resolve: Boolean?, - @Query("limit") limit: Int?, - @Query("following") following: Boolean? + @Query("q") query: String, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("following") following: Boolean? = null ): Single> @GET("api/v1/accounts/{id}") diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 5bf8c76d7..a3f717c8e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -23,12 +23,15 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat -import com.keylesspalace.tusky.ComposeActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.service.SendTootService +import com.keylesspalace.tusky.service.TootToSend import com.keylesspalace.tusky.util.NotificationHelper +import com.keylesspalace.tusky.util.randomAlphanumericString import dagger.android.AndroidInjection import javax.inject.Inject @@ -85,19 +88,25 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val sendIntent = SendTootService.sendTootIntent( context, - text, - spoiler, - visibility, - false, - emptyList(), - emptyList(), - emptyList(), - null, - citedStatusId, - null, - null, - null, - null, account, 0) + TootToSend( + text, + spoiler, + visibility.serverString(), + false, + emptyList(), + emptyList(), + emptyList(), + null, + citedStatusId, + null, + null, + null, + null, account.id, + 0, + randomAlphanumericString(16), + 0 + ) + ) context.startService(sendIntent) @@ -125,14 +134,14 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { accountManager.setActiveAccount(senderId) - val composeIntent = ComposeActivity.IntentBuilder() - .inReplyToId(citedStatusId) - .replyVisibility(visibility) - .contentWarning(spoiler) - .mentionedUsernames(mentions.toList()) - .replyingStatusAuthor(localAuthorId) - .replyingStatusContent(citedText) - .build(context) + val composeIntent = ComposeActivity.startIntent(context, ComposeOptions( + inReplyToId = citedStatusId, + replyVisibility = visibility, + contentWarning = spoiler, + mentionedUsernames = mentions.toSet(), + replyingStatusAuthor = localAuthorId, + replyingStatusContent = citedText + )) composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt index 97aac1a86..328265b5e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -8,7 +8,6 @@ import android.content.ClipData import android.content.ClipDescription import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Build import android.os.IBinder import android.os.Parcelable @@ -19,7 +18,6 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.di.Injectable @@ -28,7 +26,6 @@ import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.SaveTootHelper -import com.keylesspalace.tusky.util.randomAlphanumericString import dagger.android.AndroidInjection import kotlinx.android.parcel.Parcelize import retrofit2.Call @@ -50,7 +47,8 @@ class SendTootService : Service(), Injectable { @Inject lateinit var database: AppDatabase - private lateinit var saveTootHelper: SaveTootHelper + @Inject + lateinit var saveTootHelper: SaveTootHelper private val tootsToSend = ConcurrentHashMap() private val sendCalls = ConcurrentHashMap>() @@ -61,7 +59,6 @@ class SendTootService : Service(), Injectable { override fun onCreate() { AndroidInjection.inject(this) - saveTootHelper = SaveTootHelper(database.tootDao(), this) super.onCreate() } @@ -284,54 +281,19 @@ class SendTootService : Service(), Injectable { @JvmStatic fun sendTootIntent(context: Context, - text: String, - warningText: String, - visibility: Status.Visibility, - sensitive: Boolean, - mediaIds: List, - mediaUris: List, - mediaDescriptions: List, - scheduledAt: String?, - inReplyToId: String?, - poll: NewPoll?, - replyingStatusContent: String?, - replyingStatusAuthorUsername: String?, - savedJsonUrls: String?, - account: AccountEntity, - savedTootUid: Int + tootToSend: TootToSend ): Intent { val intent = Intent(context, SendTootService::class.java) - - val idempotencyKey = randomAlphanumericString(16) - - val tootToSend = TootToSend(text, - warningText, - visibility.serverString(), - sensitive, - mediaIds, - mediaUris.map { it.toString() }, - mediaDescriptions, - scheduledAt, - inReplyToId, - poll, - replyingStatusContent, - replyingStatusAuthorUsername, - savedJsonUrls, - account.id, - savedTootUid, - idempotencyKey, - 0) - intent.putExtra(KEY_TOOT, tootToSend) - if(mediaUris.isNotEmpty()) { + if (tootToSend.mediaUris.isNotEmpty()) { // forward uri permissions intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val uriClip = ClipData( ClipDescription("Toot Media", arrayOf("image/*", "video/*")), - ClipData.Item(mediaUris[0]) + ClipData.Item(tootToSend.mediaUris[0]) ) - mediaUris + tootToSend.mediaUris .drop(1) .forEach { mediaUri -> uriClip.addItem(ClipData.Item(mediaUri)) @@ -348,20 +310,22 @@ class SendTootService : Service(), Injectable { } @Parcelize -data class TootToSend(val text: String, - val warningText: String, - val visibility: String, - val sensitive: Boolean, - val mediaIds: List, - val mediaUris: List, - val mediaDescriptions: List, - val scheduledAt: String?, - val inReplyToId: String?, - val poll: NewPoll?, - val replyingStatusContent: String?, - val replyingStatusAuthorUsername: String?, - val savedJsonUrls: String?, - val accountId: Long, - val savedTootUid: Int, - val idempotencyKey: String, - var retries: Int) : Parcelable +data class TootToSend( + val text: String, + val warningText: String, + val visibility: String, + val sensitive: Boolean, + val mediaIds: List, + val mediaUris: List, + val mediaDescriptions: List, + val scheduledAt: String?, + val inReplyToId: String?, + val poll: NewPoll?, + val replyingStatusContent: String?, + val replyingStatusAuthorUsername: String?, + val savedJsonUrls: List?, + val accountId: Long, + val savedTootUid: Int, + val idempotencyKey: String, + var retries: Int +) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt new file mode 100644 index 000000000..b60377f52 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt @@ -0,0 +1,34 @@ +/* Copyright 2019 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.service + +import android.content.Context +import android.os.Build + +interface ServiceClient { + fun sendToot(tootToSend: TootToSend) +} + +class ServiceClientImpl(private val context: Context) : ServiceClient { + override fun sendToot(tootToSend: TootToSend) { + val intent = SendTootService.sendTootIntent(context, tootToSend) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt index f064089da..1e170da84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.service import android.annotation.TargetApi import android.content.Intent import android.service.quicksettings.TileService - import com.keylesspalace.tusky.MainActivity /** diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt new file mode 100644 index 000000000..b0048aefb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt @@ -0,0 +1,93 @@ +/* Copyright 2019 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 androidx.lifecycle.* +import io.reactivex.BackpressureStrategy +import io.reactivex.Observable +import io.reactivex.Single + +inline fun LiveData.map(crossinline mapFunction: (X) -> Y): LiveData = + Transformations.map(this) { input -> mapFunction(input) } + +inline fun LiveData.switchMap( + crossinline switchMapFunction: (X) -> LiveData +): LiveData = Transformations.switchMap(this) { input -> switchMapFunction(input) } + +inline fun LiveData.filter(crossinline predicate: (X) -> Boolean): LiveData { + val liveData = MediatorLiveData() + liveData.addSource(this) { value -> + if (predicate(value)) { + liveData.value = value + } + } + return liveData +} + +fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) = + LifecycleContext(this).apply(body) + +class LifecycleContext(val lifecycleOwner: LifecycleOwner) { + inline fun LiveData.observe(crossinline observer: (T) -> Unit) = + this.observe(lifecycleOwner, Observer { observer(it) }) + + /** + * Just hold a subscription, + */ + fun LiveData.subscribe() = + this.observe(lifecycleOwner, Observer { }) +} + +/** + * Invokes @param [combiner] when value of both @param [a] and @param [b] are not null. Returns + * [LiveData] with value set to the result of calling [combiner] with value of both. + * Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked. + */ +fun combineLiveData(a: LiveData, b: LiveData, combiner: (A, B) -> R): LiveData { + val liveData = MediatorLiveData() + liveData.addSource(a) { + if (a.value != null && b.value != null) { + liveData.value = combiner(a.value!!, b.value!!) + } + } + liveData.addSource(b) { + if (a.value != null && b.value != null) { + liveData.value = combiner(a.value!!, b.value!!) + } + } + return liveData +} + +/** + * Returns [LiveData] with value set to the result of calling [combiner] with value of [a] and [b] + * after either changes. Doesn't check if either has value. + * Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked. + */ +fun combineOptionalLiveData(a: LiveData, b: LiveData, combiner: (A?, B?) -> R): LiveData { + val liveData = MediatorLiveData() + liveData.addSource(a) { + liveData.value = combiner(a.value, b.value) + } + liveData.addSource(b) { + liveData.value = combiner(a.value, b.value) + } + return liveData +} + +fun Single.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable()) +fun Observable.toLiveData( + backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST +) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST)) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java index edc8ab92d..690098309 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java @@ -5,16 +5,18 @@ import android.content.ContentResolver; import android.content.Context; import android.net.Uri; import android.os.AsyncTask; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.FileProvider; import android.text.TextUtils; import android.util.Log; import android.webkit.MimeTypeMap; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; + import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import com.keylesspalace.tusky.BuildConfig; +import com.keylesspalace.tusky.db.AppDatabase; import com.keylesspalace.tusky.db.TootDao; import com.keylesspalace.tusky.db.TootEntity; import com.keylesspalace.tusky.entity.NewPoll; @@ -27,6 +29,8 @@ import java.util.Date; import java.util.List; import java.util.Locale; +import javax.inject.Inject; + public final class SaveTootHelper { private static final String TAG = "SaveTootHelper"; @@ -35,15 +39,16 @@ public final class SaveTootHelper { private Context context; private Gson gson = new Gson(); - public SaveTootHelper(@NonNull TootDao tootDao, @NonNull Context context) { - this.tootDao = tootDao; + @Inject + public SaveTootHelper(@NonNull AppDatabase appDatabase, @NonNull Context context) { + this.tootDao = appDatabase.tootDao(); this.context = context; } @SuppressLint("StaticFieldLeak") public boolean saveToot(@NonNull String content, @NonNull String contentWarning, - @Nullable String savedJsonUrls, + @Nullable List savedJsonUrls, @NonNull List mediaUris, @NonNull List mediaDescriptions, int savedTootUid, @@ -58,31 +63,25 @@ public final class SaveTootHelper { } // Get any existing file's URIs. - ArrayList existingUris = null; - if (!TextUtils.isEmpty(savedJsonUrls)) { - existingUris = gson.fromJson(savedJsonUrls, - new TypeToken>() { - }.getType()); - } String mediaUrlsSerialized = null; String mediaDescriptionsSerialized = null; if (!ListUtils.isEmpty(mediaUris)) { - List savedList = saveMedia(mediaUris, existingUris); + List savedList = saveMedia(mediaUris, savedJsonUrls); if (!ListUtils.isEmpty(savedList)) { mediaUrlsSerialized = gson.toJson(savedList); - if (!ListUtils.isEmpty(existingUris)) { - deleteMedia(setDifference(existingUris, savedList)); + if (!ListUtils.isEmpty(savedJsonUrls)) { + deleteMedia(setDifference(savedJsonUrls, savedList)); } } else { return false; } mediaDescriptionsSerialized = gson.toJson(mediaDescriptions); - } else if (!ListUtils.isEmpty(existingUris)) { + } else if (!ListUtils.isEmpty(savedJsonUrls)) { /* If there were URIs in the previous draft, but they've now been removed, those files * can be deleted. */ - deleteMedia(existingUris); + deleteMedia(savedJsonUrls); } final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, mediaDescriptionsSerialized, contentWarning, inReplyToId, @@ -103,15 +102,16 @@ public final class SaveTootHelper { public void deleteDraft(int tootId) { TootEntity item = tootDao.find(tootId); - if(item != null) { + if (item != null) { deleteDraft(item); } } - public void deleteDraft(@NonNull TootEntity item){ + public void deleteDraft(@NonNull TootEntity item) { // Delete any media files associated with the status. ArrayList uris = gson.fromJson(item.getUrls(), - new TypeToken>() {}.getType()); + new TypeToken>() { + }.getType()); if (uris != null) { for (String uriString : uris) { Uri uri = Uri.parse(uriString); @@ -172,7 +172,7 @@ public final class SaveTootHelper { } return null; } - Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID+".fileprovider", file); + Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file); results.add(resultUri.toString()); } return results; diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt index b976a5d63..389995ae2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt @@ -51,4 +51,13 @@ inline fun EditText.onTextChanged( callback(s, start, before, count) } }) +} + +inline fun EditText.afterTextChanged( + crossinline callback: (s: Editable) -> Unit) { + addTextChangedListener(object : DefaultTextWatcher() { + override fun afterTextChanged(s: Editable) { + callback(s) + } + }) } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 410d04db4..e7f936392 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -2,7 +2,7 @@ @@ -30,10 +30,9 @@ android:layout_gravity="end" android:padding="8dp" android:text="@string/at_symbol" - android:textStyle="bold" android:textColor="?android:textColorTertiary" android:textSize="?attr/status_text_large" - /> + android:textStyle="bold" /> + android:textStyle="bold" /> - - - - - - - - - - + android:scrollbars="none" /> + @@ -174,7 +166,7 @@ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> - - + app:srcCompat="@drawable/ic_cw_24dp" /> - + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" /> \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 29078170d..17754eb75 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -487,7 +487,6 @@ التبويقات المبَرمَجة برمجة تبويق صفّر - اضغط هنا لضبط برمجة التبويق. خطأ أثناء البحث عن منشور %s الفواصل المرجعية diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index a0e689a4e..ac95bd748 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -508,7 +508,6 @@ নির্ধারিত টুটগুলি নির্ধারিত টুট রিসেট - নির্ধারিত টুট কনফিগার করতে এখানে আলতো চাপুন। টাস্কি দ্বারা চালিত %s পোস্ট অনুসন্ধানে ত্রুটি diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 6027dbe1b..3bf95b18b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -526,7 +526,6 @@ Toots programats Programar el toot Reiniciar - Clica aquí per configurar el toot programat. Desenvolupat per Tusky Afegit a les adreces d\'interès Seleccionar la llista diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index afd889352..887a34eaa 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -470,7 +470,6 @@ Plánované tooty Naplánovat toot Obnovit - Klepnutím sem nastavíte plánovaný toot. Vždy rozbalovat tooty označené varováními o obsahu Celé slovo Je-li klíčové slovo nebo fráze pouze alfanumerická, bude použita pouze, pokud odpovídá celému slovu diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f010ac66f..45a1ee7ca 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -451,6 +451,5 @@ Geplante Beiträge Plane Beitrag Zurücksetzen - Drücke hier, um den geplanten Beitrag zu konfigurieren. Dies sind Zeitstempel für Status. Beispiele: \"16s\" oder \"2t\". diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index cacefd8db..c5dc9aa49 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -464,7 +464,6 @@ Planitaj mesaĝoj Plani mesaĝon Restarigi - Frapetu ĉi-tie por agordi la planitan mesaĝon. Funkciigita de Tusky Aldonita al la legosignoj Elekti la liston diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5d2e4887e..cea35b629 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -465,7 +465,6 @@ Estados programados Programar estado Reiniciar - Pulsa aquí para configurar un estado programado. Error al buscar el post %s Potenciado por Tusky diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index ccac69005..c40853527 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -311,7 +311,6 @@ %s ez dago ezkutatua - Sakatu hemen programatutako tuta konfiguratzeko. Tut hau ezabatu eta zirriborro berria egin\? Ziur al zaude %s ezabatu nahi duzula\? Domeinu horretatik datorren edukia ez duzu denbora-lerro publikoetan edo jakinarazpenentan ikusiko. Domeinu horretan dituzun jarraitzaileak ezabatuko dira. Domeinu osoa ezkutatu diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index fc4b7ab89..b56ecbe73 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -460,7 +460,6 @@ بوق‌های زمان‌بندی‌شده زمان‌بندی بوق بازنشانی - برای پیکربندی بوق زمان‌بندی‌شده، این‌جا را بزنید. مطمئنید می‌خواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچ‌یک از خط زمانی‌ها یا در آگاهی‌هایتان نخواهید دید. پیروانتان از آن دامنه، برداشته خواهند شد. هنگامی که کلیدواژه یا عبارت، فقط حروف‌عددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد عبارت پالایش diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 88be37d92..3bd439b9b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -472,7 +472,6 @@ Pouets planifiés Planifier le pouet Réinitialiser - Appuyez ici pour configurer le pouet planifié. Erreur lors de la récupération du message %s Propulsé par Tusky diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 5197d5974..1cb2bb70b 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -470,7 +470,6 @@ Időzített tülkök Tülk Időzítése Visszaállítás - Ide nyúlj az időzített tülkök beállításához. Nem találjuk ezt a posztot %s Könyvjelzők diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4329dbaa3..60d705aa8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -476,6 +476,5 @@ Toot programmati Programma un toot RIpristina - Tocca qui configurare i toot programmati. %1$s • %2$s diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 90e692c49..a30975278 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -81,7 +81,6 @@ diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index 2b786c319..3f57899b4 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -507,7 +507,6 @@ Planlagte toots Planlegg toot Tilbakestill - Klikk her for å konfigurere planlagt toot. Det oppsto en feil under henting av %s Drevet av Tusky diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index b731af9c9..9118095f5 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -477,7 +477,6 @@ Tuts planificats Planificar de tuts Escafar - Tocatz aquí per configurar los tuts planificats. Error en cercant la publicacion %s Propulsat per Tusky diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index df69f180d..ab6400252 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -484,7 +484,6 @@ Zaplanowane wpisy Zaplanuj wpis Resetuj - Dotknij tutaj, żeby skonfigurować zaplanowany wpis. Napędzane przez Tusky Błąd przy wyszukiwaniu wpisu %s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9626a86ed..a9ea2f2fa 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -472,7 +472,6 @@ Agendados Agendar toot Cancelar - Toque aqui para agendar Erro ao pesquisar %s Salvos diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bb04ff055..ae47c609c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -540,7 +540,6 @@ Отложенные записи Отложить запись Сброс - Нажмите для выбора времени отправки. Ошибка при поиске сообщения / ний Закладки diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index fb4994c09..1d70f3b72 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -521,7 +521,6 @@ Napovedani tuti Ponastavi Napovej tut - Dotaknite se tukaj, da nastavite napovedan tut. Napaka pri iskanju objave %s Poganja ga Tusky diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 28cda6262..96b6f8141 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -468,7 +468,6 @@ Schemalagda toots Schemalägg toot Återställ - Knacka här för att konfigurera schemalagd toot. Fel vid uppslagning av status %s diff --git a/app/src/main/res/values-sw360dp/toot_button.xml b/app/src/main/res/values-sw380dp/toot_button.xml similarity index 100% rename from app/src/main/res/values-sw360dp/toot_button.xml rename to app/src/main/res/values-sw380dp/toot_button.xml diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index bc7c8d352..77be2810d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -435,7 +435,6 @@ Zamanlanmış iletiler İleti zamanla Sıfırla - Zamanlanmış iletiyi yapılandırmak için buraya dokunun. Bu iletiyi silip yeniden düzenlemek istiyor musun\? Botlar için gösterge göster Tusky tarafından desteklenmektedir diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f06e34dfc..1ede742d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,7 +159,6 @@ Which instance? What\'s happening? - Tap here to configure scheduled toot. Content warning Display name Bio diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 8251dfd63..826601989 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -144,7 +144,6 @@ diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index c8a06252f..a14ebcd10 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -18,16 +18,21 @@ package com.keylesspalace.tusky import android.text.SpannedString import android.widget.EditText -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.InstanceDao +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeViewModel +import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT +import com.keylesspalace.tusky.components.compose.MediaUploader +import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.network.MastodonApi -import okhttp3.Request -import org.junit.Assert +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.util.SaveTootHelper +import com.nhaarman.mockitokotlin2.any +import io.reactivex.Single +import io.reactivex.SingleObserver import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -35,15 +40,8 @@ import org.junit.runner.RunWith import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.robolectric.Robolectric -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.reactivex.Single -import io.reactivex.SingleObserver import org.robolectric.annotation.Config import org.robolectric.fakes.RoboMenuItem -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - /** * Created by charlag on 3/7/18. @@ -52,14 +50,15 @@ import retrofit2.Response @Config(application = FakeTuskyApplication::class, sdk = [28]) @RunWith(AndroidJUnit4::class) class ComposeActivityTest { - private lateinit var activity: ComposeActivity private lateinit var accountManagerMock: AccountManager private lateinit var apiMock: MastodonApi + private val instanceDomain = "example.domain" + private val account = AccountEntity( id = 1, - domain = "example.token", + domain = instanceDomain, accessToken = "token", isActive = true, accountId = "1", @@ -83,30 +82,10 @@ class ComposeActivityTest { activity = controller.get() accountManagerMock = mock(AccountManager::class.java) + `when`(accountManagerMock.activeAccount).thenReturn(account) apiMock = mock(MastodonApi::class.java) - `when`(apiMock.getCustomEmojis()).thenReturn(object: Call> { - override fun isExecuted(): Boolean { - return false - } - override fun clone(): Call> { - throw Error("not implemented") - } - override fun isCanceled(): Boolean { - throw Error("not implemented") - } - override fun cancel() { - throw Error("not implemented") - } - override fun execute(): Response> { - throw Error("not implemented") - } - override fun request(): Request { - throw Error("not implemented") - } - - override fun enqueue(callback: Callback>?) {} - }) + `when`(apiMock.getCustomEmojis()).thenReturn(Single.just(emptyList())) `when`(apiMock.getInstance()).thenReturn(object: Single() { override fun subscribeActual(observer: SingleObserver) { val instance = instanceResponseCallback?.invoke() @@ -119,15 +98,27 @@ class ComposeActivityTest { }) val instanceDaoMock = mock(InstanceDao::class.java) + `when`(instanceDaoMock.loadMetadataForInstance(any())).thenReturn( + Single.just(InstanceEntity(instanceDomain, emptyList(),null, null, null, null)) + ) + val dbMock = mock(AppDatabase::class.java) `when`(dbMock.instanceDao()).thenReturn(instanceDaoMock) - activity.mastodonApi = apiMock + val viewModel = ComposeViewModel( + apiMock, + accountManagerMock, + mock(MediaUploader::class.java), + mock(ServiceClient::class.java), + mock(SaveTootHelper::class.java), + dbMock + ) + + val viewModelFactoryMock = mock(ViewModelFactory::class.java) + `when`(viewModelFactoryMock.create(ComposeViewModel::class.java)).thenReturn(viewModel) + activity.accountManager = accountManagerMock - activity.database = dbMock - - `when`(accountManagerMock.activeAccount).thenReturn(account) - + activity.viewModelFactory = viewModelFactoryMock controller.create().start() } @@ -164,7 +155,7 @@ class ComposeActivityTest { fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() { instanceResponseCallback = { getInstanceWithMaximumTootCharacters(null) } setupActivity() - assertEquals(ComposeActivity.STATUS_CHARACTER_LIMIT, activity.maximumTootCharacters) + assertEquals(DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters) } @Test @@ -196,7 +187,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " insertSomeTextInContent(shortUrl + additionalContent + url) - Assert.assertEquals(activity.calculateTextLength(), additionalContent.length + shortUrl.length + ComposeActivity.MAXIMUM_URL_LENGTH) + assertEquals(activity.calculateTextLength(), additionalContent.length + shortUrl.length + ComposeActivity.MAXIMUM_URL_LENGTH) } @Test @@ -204,7 +195,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " insertSomeTextInContent(url + additionalContent + url) - Assert.assertEquals(activity.calculateTextLength(), additionalContent.length + (ComposeActivity.MAXIMUM_URL_LENGTH * 2)) + assertEquals(activity.calculateTextLength(), additionalContent.length + (ComposeActivity.MAXIMUM_URL_LENGTH * 2)) } private fun clickUp() { @@ -256,13 +247,5 @@ class ComposeActivityTest { ) } - private fun getSuccessResponseCallbackWithMaximumTootCharacters(maximumTootCharacters: Int?): (Call?, Callback?) -> Unit - { - return { - call: Call?, callback: Callback? -> - if (call != null) { - callback?.onResponse(call, Response.success(getInstanceWithMaximumTootCharacters(maximumTootCharacters))) - } - } - } -} \ No newline at end of file +} + From a8f3070889be30286d2395a13490ce73f2647e96 Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Fri, 20 Dec 2019 21:14:22 +0300 Subject: [PATCH 20/29] ScheduledTootActivity: fix toolbar back button #1586 (#1591) --- .../com/keylesspalace/tusky/ScheduledTootActivity.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt index 0bdd14e8a..5467a3b87 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View +import android.view.MenuItem import androidx.appcompat.widget.Toolbar import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.DividerItemDecoration @@ -80,6 +81,16 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootAdapter.ScheduledToot } } + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + fun loadStatuses() { progress_bar.visibility = View.VISIBLE mastodonApi.scheduledStatuses() From 60b9a9c40b843e115067ab406a15bf9c72f6a660 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Fri, 20 Dec 2019 19:52:36 +0100 Subject: [PATCH 21/29] upgrade SparkButton to 3.0.0 (#1594) --- app/build.gradle | 2 +- .../tusky/adapter/StatusBaseViewHolder.java | 59 ++++--------------- 2 files changed, 13 insertions(+), 48 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index d4fff4f36..65a384a1b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,7 +151,7 @@ dependencies { implementation "com.google.dagger:dagger-android-support:$daggerVersion" kapt "com.google.dagger:dagger-android-processor:$daggerVersion" - implementation "com.github.connyduck:sparkbutton:2.0.1" + implementation "com.github.connyduck:sparkbutton:3.0.0" implementation "com.github.chrisbanes:PhotoView:2.3.0" diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 5b74ca088..3bb83dfdf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -565,60 +565,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } }); if (reblogButton != null) { - reblogButton.setEventListener(new SparkEventListener() { - @Override - public void onEvent(ImageView button, boolean buttonState) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onReblog(buttonState, position); - } - } - - @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) { - } - - @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) { + reblogButton.setEventListener((button, buttonState) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onReblog(buttonState, position); } }); } - favouriteButton.setEventListener(new SparkEventListener() { - @Override - public void onEvent(ImageView button, boolean buttonState) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onFavourite(buttonState, position); - } - } - - @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) { - } - - @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) { + favouriteButton.setEventListener((button, buttonState) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onFavourite(buttonState, position); } }); - bookmarkButton.setEventListener(new SparkEventListener() { - @Override - public void onEvent(ImageView button, boolean buttonState) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onBookmark(buttonState, position); - } - } - - @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) { - - } - - @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) { - + bookmarkButton.setEventListener((button, buttonState) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onBookmark(buttonState, position); } }); From 516546a3bf232466fa010abfd51b9370ca53892e Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Fri, 20 Dec 2019 20:08:02 +0100 Subject: [PATCH 22/29] upgrade androidx.browser to 1.2.0 (#1595) --- app/build.gradle | 3 ++- .../com/keylesspalace/tusky/LoginActivity.kt | 14 +++++++++++--- .../com/keylesspalace/tusky/util/LinkHelper.java | 16 ++++++++++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 65a384a1b..0c4ab184e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -107,7 +107,8 @@ dependencies { implementation "androidx.core:core-ktx:1.2.0-beta01" implementation "androidx.appcompat:appcompat:1.1.0" implementation "androidx.fragment:fragment-ktx:1.1.0" - implementation "androidx.browser:browser:1.0.0" + implementation "androidx.browser:browser:1.2.0" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" implementation "androidx.recyclerview:recyclerview:1.0.0" implementation "androidx.exifinterface:exifinterface:1.0.0" implementation "androidx.cardview:cardview:1.0.0" diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt index b7f472738..f659ab860 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt @@ -20,14 +20,15 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri +import android.os.Build import android.os.Bundle -import androidx.browser.customtabs.CustomTabsIntent import android.text.method.LinkMovementMethod import android.util.Log import android.view.MenuItem import android.view.View import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.browser.customtabs.CustomTabsIntent import com.bumptech.glide.Glide import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.AccessToken @@ -345,9 +346,16 @@ class LoginActivity : BaseActivity(), Injectable { private fun openInCustomTab(uri: Uri, context: Context): Boolean { val toolbarColor = ThemeUtils.getColor(context, R.attr.custom_tab_toolbar) - val customTabsIntent = CustomTabsIntent.Builder() + val customTabsIntentBuilder = CustomTabsIntent.Builder() .setToolbarColor(toolbarColor) - .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + customTabsIntentBuilder.setNavigationBarColor( + ThemeUtils.getColor(context, android.R.attr.navigationBarColor) + ) + } + + val customTabsIntent = customTabsIntentBuilder.build() try { customTabsIntent.launchUrl(context, uri) } catch (e: ActivityNotFoundException) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java index 606a36a9b..bcc2ce5c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -19,6 +19,7 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Build; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.method.LinkMovementMethod; @@ -68,7 +69,7 @@ public class LinkHelper { * @param listener to notify about particular spans that are clicked */ public static void setClickableText(TextView view, Spanned content, - @Nullable Status.Mention[] mentions, final LinkListener listener) { + @Nullable Status.Mention[] mentions, final LinkListener listener) { SpannableStringBuilder builder = new SpannableStringBuilder(content); URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class); for (URLSpan span : urlSpans) { @@ -221,10 +222,17 @@ public class LinkHelper { public static void openLinkInCustomTab(Uri uri, Context context) { int toolbarColor = ThemeUtils.getColor(context, R.attr.custom_tab_toolbar); - CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() + CustomTabsIntent.Builder customTabsIntentBuilder = new CustomTabsIntent.Builder() .setToolbarColor(toolbarColor) - .setShowTitle(true) - .build(); + .setShowTitle(true); + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + customTabsIntentBuilder.setNavigationBarColor( + ThemeUtils.getColor(context, android.R.attr.navigationBarColor) + ); + } + + CustomTabsIntent customTabsIntent = customTabsIntentBuilder.build(); try { customTabsIntent.launchUrl(context, uri); } catch (ActivityNotFoundException e) { From e67d608bb803b5d2950377f41c9de559480001c0 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Fri, 20 Dec 2019 21:31:41 +0100 Subject: [PATCH 23/29] upgrade dependencies (#1596) --- app/build.gradle | 22 +++++++++++----------- build.gradle | 6 +++--- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0c4ab184e..0386f0df3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -93,39 +93,39 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { } ext.lifecycleVersion = "2.1.0" -ext.roomVersion = '2.2.1' +ext.roomVersion = '2.2.3' ext.retrofitVersion = '2.6.0' ext.okhttpVersion = '4.2.2' ext.glideVersion = '4.10.0' -ext.daggerVersion = '2.25.2' +ext.daggerVersion = '2.25.3' // if libraries are changed here, they should also be changed in LicenseActivity dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "androidx.core:core-ktx:1.2.0-beta01" + implementation "androidx.core:core-ktx:1.2.0-rc01" implementation "androidx.appcompat:appcompat:1.1.0" implementation "androidx.fragment:fragment-ktx:1.1.0" implementation "androidx.browser:browser:1.2.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" - implementation "androidx.recyclerview:recyclerview:1.0.0" - implementation "androidx.exifinterface:exifinterface:1.0.0" + implementation "androidx.recyclerview:recyclerview:1.1.0" + implementation "androidx.exifinterface:exifinterface:1.1.0" implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.preference:preference:1.1.0" - implementation "androidx.sharetarget:sharetarget:1.0.0-beta01" + implementation "androidx.sharetarget:sharetarget:1.0.0-rc01" implementation "androidx.emoji:emoji:1.0.0" implementation "androidx.emoji:emoji-appcompat:1.0.0" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-reactivestreams:$lifecycleVersion" implementation "androidx.constraintlayout:constraintlayout:1.1.3" - implementation "androidx.paging:paging-runtime-ktx:2.1.0" - implementation "androidx.viewpager2:viewpager2:1.0.0-rc01" + implementation "androidx.paging:paging-runtime-ktx:2.1.1" + implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.room:room-runtime:$roomVersion" implementation "androidx.room:room-rxjava2:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion" - implementation "com.google.android.material:material:1.1.0-beta01" + implementation "com.google.android.material:material:1.1.0-rc01" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" @@ -139,7 +139,7 @@ dependencies { implementation "com.github.bumptech.glide:glide:$glideVersion" implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" - implementation "io.reactivex.rxjava2:rxjava:2.2.13" + implementation "io.reactivex.rxjava2:rxjava:2.2.16" implementation "io.reactivex.rxjava2:rxandroid:2.1.1" implementation "io.reactivex.rxjava2:rxkotlin:2.4.0" @@ -172,7 +172,7 @@ dependencies { testImplementation "androidx.test.ext:junit:1.1.1" testImplementation "org.robolectric:robolectric:4.3.1" - testImplementation "org.mockito:mockito-inline:3.1.0" + testImplementation "org.mockito:mockito-inline:3.2.4" testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" androidTestImplementation("androidx.test.espresso:espresso-core:3.1.1", { diff --git a/build.gradle b/build.gradle index 265660c6f..9d9a324e0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,14 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.3.61' repositories { jcenter() google() } dependencies { - classpath 'com.android.tools.build.jetifier:jetifier-processor:1.0.0-beta07' - classpath 'com.android.tools.build:gradle:3.5.2' + classpath 'com.android.tools.build.jetifier:jetifier-processor:1.0.0-beta08' + classpath 'com.android.tools.build:gradle:3.5.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ee69dd68d..1ba7206f8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.0.1-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 82e13db28f29b0a23153ae3398c8ae9048c24c82 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Sat, 21 Dec 2019 15:55:55 +0100 Subject: [PATCH 24/29] fix imagebutton color (#1598) --- app/src/main/res/values-night/styles.xml | 1 + app/src/main/res/values/styles.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index a30975278..90e692c49 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -81,6 +81,7 @@ diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 826601989..8251dfd63 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -144,6 +144,7 @@ From eca3b5379e27a7604d0873839697fb847e495231 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Sat, 21 Dec 2019 15:56:07 +0100 Subject: [PATCH 25/29] fix delete&redraft losing toot text (#1599) --- .../tusky/components/compose/ComposeViewModel.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 05cf31b13..db070a96a 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 @@ -406,10 +406,9 @@ class ComposeViewModel } - composeOptions?.savedTootUid?.let { uid -> - this.savedTootUid = uid - startingText = composeOptions.tootText - } + savedTootUid = composeOptions?.savedTootUid ?: 0 + startingText = composeOptions?.tootText + val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { From a795da81b489d2e74ee04d343051215f14861fb5 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Sat, 21 Dec 2019 15:56:19 +0100 Subject: [PATCH 26/29] fix replying to toots (#1600) --- .../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 db070a96a..cbcb29ce4 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 @@ -266,7 +266,7 @@ class ComposeViewModel mediaUris.map { it.toString() }, mediaDescriptions, scheduledAt = scheduledAt.value, - inReplyToId = null, + inReplyToId = inReplyToId, poll = poll.value, replyingStatusContent = null, replyingStatusAuthorUsername = null, From c253f6b23bf9a1f2736ea9ecef9b7af4cde120c2 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Sat, 21 Dec 2019 17:32:42 +0100 Subject: [PATCH 27/29] delete unused stuff (#1601) --- .../res/mipmap-hdpi/ic_shortcut_compose.png | Bin 4111 -> 0 bytes .../res/mipmap-mdpi/ic_shortcut_compose.png | Bin 2405 -> 0 bytes .../res/mipmap-xhdpi/ic_shortcut_compose.png | Bin 6204 -> 0 bytes .../res/mipmap-xxhdpi/ic_shortcut_compose.png | Bin 10214 -> 0 bytes .../mipmap-xxxhdpi/ic_shortcut_compose.png | Bin 13210 -> 0 bytes .../tusky/util/CountUpDownLatch.java | 51 ------------------ .../keylesspalace/tusky/util/MediaUtils.kt | 23 -------- .../mipmap-anydpi-v26/ic_shortcut_compose.xml | 9 ---- 8 files changed, 83 deletions(-) delete mode 100644 app/src/green/res/mipmap-hdpi/ic_shortcut_compose.png delete mode 100644 app/src/green/res/mipmap-mdpi/ic_shortcut_compose.png delete mode 100644 app/src/green/res/mipmap-xhdpi/ic_shortcut_compose.png delete mode 100644 app/src/green/res/mipmap-xxhdpi/ic_shortcut_compose.png delete mode 100644 app/src/green/res/mipmap-xxxhdpi/ic_shortcut_compose.png delete mode 100644 app/src/main/java/com/keylesspalace/tusky/util/CountUpDownLatch.java delete mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_shortcut_compose.xml diff --git a/app/src/green/res/mipmap-hdpi/ic_shortcut_compose.png b/app/src/green/res/mipmap-hdpi/ic_shortcut_compose.png deleted file mode 100644 index 422323b271cbf2a88eb0dd6181ca8bca4521d5aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4111 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 diff --git a/app/src/green/res/mipmap-xhdpi/ic_shortcut_compose.png b/app/src/green/res/mipmap-xhdpi/ic_shortcut_compose.png deleted file mode 100644 index eb226e091fd2c0836984fc5722dbb492a9af2b8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6204 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 diff --git a/app/src/green/res/mipmap-xxxhdpi/ic_shortcut_compose.png b/app/src/green/res/mipmap-xxxhdpi/ic_shortcut_compose.png deleted file mode 100644 index 415db184917b35b1940d92c16b1b375a8674a49a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13210 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. */ - -package com.keylesspalace.tusky.util; - -/** - * This is a synchronization primitive related to {@link java.util.concurrent.CountDownLatch} - * except that it starts at zero and can count upward. - *

- * The intended use case is for waiting for all tasks to be finished when the number of tasks isn't - * known ahead of time, or may change while waiting. - */ -public class CountUpDownLatch { - private int count; - - public CountUpDownLatch() { - this.count = 0; - } - - public synchronized void countDown() { - count--; - notifyAll(); - } - - public synchronized void countUp() { - count++; - notifyAll(); - } - - public synchronized void await() throws InterruptedException { - while (count != 0) { - wait(); - } - } - - public synchronized boolean isEmpty() { - return count == 0; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt index 453b42d2a..43f05e9cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -16,13 +16,10 @@ package com.keylesspalace.tusky.util import android.content.ContentResolver -import android.content.Context import android.database.Cursor import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix -import android.media.MediaMetadataRetriever -import android.media.ThumbnailUtils import android.net.Uri import android.provider.OpenableColumns import androidx.annotation.Px @@ -106,26 +103,6 @@ fun getSampledBitmap(contentResolver: ContentResolver, uri: Uri, @Px reqWidth: I } } -fun getImageThumbnail(contentResolver: ContentResolver, uri: Uri, @Px thumbnailSize: Int): Bitmap? { - val source = getSampledBitmap(contentResolver, uri, thumbnailSize, thumbnailSize) ?: return null - return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT) -} - -fun getVideoThumbnail(context: Context, uri: Uri, @Px thumbnailSize: Int): Bitmap? { - val retriever = MediaMetadataRetriever() - try { - retriever.setDataSource(context, uri) - } catch (e: IllegalArgumentException) { - Log.w(TAG, e) - return null - } catch (e: SecurityException) { - Log.w(TAG, e) - return null - } - val source = retriever.frameAtTime ?: return null - return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT) -} - @Throws(FileNotFoundException::class) fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long { val input = contentResolver.openInputStream(uri) diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_compose.xml b/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_compose.xml deleted file mode 100644 index 5ea9201d2..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_compose.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file From c44dd455b413b91cda6567c29b6193d334d16a98 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Sat, 21 Dec 2019 18:56:16 +0100 Subject: [PATCH 28/29] Implement identity proof api (#1597) * implement identity proof api * fix warnings in AccountActivity * fix createClickableText method * improve error handling * use combineOptionalLiveData to simplify code --- .../keylesspalace/tusky/AccountActivity.kt | 16 ++++--- .../tusky/adapter/AccountFieldAdapter.kt | 31 ++++++++++---- .../adapter/StatusDetailedViewHolder.java | 5 +-- .../tusky/entity/IdentityProof.kt | 9 ++++ .../tusky/network/MastodonApi.kt | 5 +++ .../keylesspalace/tusky/util/LinkHelper.java | 8 ++++ .../tusky/viewmodel/AccountViewModel.kt | 42 +++++++++++++++++-- 7 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt index 14a23bacf..85a34536e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt @@ -19,8 +19,7 @@ import android.animation.ArgbEvaluator import android.content.Context import android.content.Intent import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.PorterDuff +import android.graphics.* import android.os.Bundle import android.view.Menu import android.view.MenuItem @@ -52,6 +51,8 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.LinkListener @@ -118,7 +119,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewModel = ViewModelProviders.of(this, viewModelFactory)[AccountViewModel::class.java] // Obtain information to fill out the profile. - viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)) + viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this) animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false) @@ -350,6 +351,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } }) + viewModel.accountFieldData.observe(this, Observer>> { + accountFieldAdapter.fields = it + accountFieldAdapter.notifyDataSetChanged() + + }) } /** @@ -378,7 +384,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView) LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this) - accountFieldAdapter.fields = account.fields ?: emptyList() + // accountFieldAdapter.fields = account.fields ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList() accountFieldAdapter.notifyDataSetChanged() @@ -472,7 +478,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI // this is necessary because API 19 can't handle vector compound drawables val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate() val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) - movedIcon?.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) + movedIcon?.colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt index 8e94c7063..710b0d9b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.adapter +import android.text.method.LinkMovementMethod import androidx.recyclerview.widget.RecyclerView import android.view.LayoutInflater import android.view.ViewGroup @@ -23,15 +24,17 @@ import android.widget.TextView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.CustomEmojiHelper +import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.LinkHelper import kotlinx.android.synthetic.main.item_account_field.view.* class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter() { var emojis: List = emptyList() - var fields: List = emptyList() + var fields: List> = emptyList() override fun getItemCount() = fields.size @@ -41,18 +44,30 @@ class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView } override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val field = fields[position] + val proofOrField = fields[position] - val emojifiedName = CustomEmojiHelper.emojifyString(field.name, emojis, viewHolder.nameTextView) - viewHolder.nameTextView.text = emojifiedName + if(proofOrField.isLeft()) { + val identityProof = proofOrField.asLeft() - val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView) - LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener) + viewHolder.nameTextView.text = identityProof.provider + viewHolder.valueTextView.text = LinkHelper.createClickableText(identityProof.username, identityProof.profileUrl) + + viewHolder.valueTextView.movementMethod = LinkMovementMethod.getInstance() - if(field.verifiedAt != null) { viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) } else { - viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 ) + val field = proofOrField.asRight() + val emojifiedName = CustomEmojiHelper.emojifyString(field.name, emojis, viewHolder.nameTextView) + viewHolder.nameTextView.text = emojifiedName + + val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView) + LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener) + + if(field.verifiedAt != null) { + viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) + } else { + viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 ) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index df0fb9231..b94b4cb71 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -115,10 +115,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { timestampInfo.append(" • "); if (app.getWebsite() != null) { - URLSpan span = new CustomURLSpan(app.getWebsite()); - - SpannableStringBuilder text = new SpannableStringBuilder(app.getName()); - text.setSpan(span, 0, app.getName().length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); timestampInfo.append(text); timestampInfo.setMovementMethod(LinkMovementMethod.getInstance()); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt new file mode 100644 index 000000000..9473f0372 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt @@ -0,0 +1,9 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class IdentityProof( + val provider: String, + @SerializedName("provider_username") val username: String, + @SerializedName("profile_url") val profileUrl: 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 2fb3f9408..65d096e8d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -318,6 +318,11 @@ interface MastodonApi { @Query("id[]") accountIds: List ): Call> + @GET("api/v1/accounts/{id}/identity_proofs") + fun identityProofs( + @Path("id") accountId: String + ): Call> + @GET("api/v1/blocks") fun blocks( @Query("max_id") maxId: String? diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java index bcc2ce5c3..85e6f2866 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -179,6 +179,14 @@ public class LinkHelper { view.setMovementMethod(LinkMovementMethod.getInstance()); } + public static CharSequence createClickableText(String text, String link) { + URLSpan span = new CustomURLSpan(link); + + SpannableStringBuilder clickableText = new SpannableStringBuilder(text); + clickableText.setSpan(span, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + return clickableText; + } + /** * Opens a link, depending on the settings, either in the browser or in a custom tab * diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt index adbcaa43e..ad90cacc9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt @@ -6,12 +6,11 @@ import androidx.lifecycle.ViewModel import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Error -import com.keylesspalace.tusky.util.Loading -import com.keylesspalace.tusky.util.Resource -import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.* import io.reactivex.disposables.Disposable import retrofit2.Call import retrofit2.Callback @@ -27,6 +26,14 @@ class AccountViewModel @Inject constructor( val accountData = MutableLiveData>() val relationshipData = MutableLiveData>() + private val identityProofData = MutableLiveData>() + + val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs -> + identityProofs.orEmpty().map { Either.Left(it) } + .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) + } + + private val callList: MutableList> = mutableListOf() private val disposable: Disposable = eventHub.events .subscribe { event -> @@ -60,6 +67,7 @@ class AccountViewModel @Inject constructor( } override fun onFailure(call: Call, t: Throwable) { + Log.w(TAG, "failed obtaining account", t) accountData.postValue(Error()) isDataLoading = false isRefreshing.postValue(false) @@ -90,6 +98,7 @@ class AccountViewModel @Inject constructor( } override fun onFailure(call: Call>, t: Throwable) { + Log.w(TAG, "failed obtaining relationships", t) relationshipData.postValue(Error()) } }) @@ -98,6 +107,30 @@ class AccountViewModel @Inject constructor( } } + private fun obtainIdentityProof(reload: Boolean = false) { + if (identityProofData.value == null || reload) { + + val call = mastodonApi.identityProofs(accountId) + call.enqueue(object : Callback> { + override fun onResponse(call: Call>, + response: Response>) { + val proofs = response.body() + if (response.isSuccessful && proofs != null ) { + identityProofData.postValue(proofs) + } else { + identityProofData.postValue(emptyList()) + } + } + + override fun onFailure(call: Call>, t: Throwable) { + Log.w(TAG, "failed obtaining identity proofs", t) + } + }) + + callList.add(call) + } + } + fun changeFollowState() { val relationship = relationshipData.value?.data if (relationship?.following == true || relationship?.requested == true) { @@ -227,6 +260,7 @@ class AccountViewModel @Inject constructor( return accountId.let { obtainAccount(isReload) + obtainIdentityProof() if (!isSelf) obtainRelationship(isReload) } From ce2ee660ae4dcbe953db5193a3b211d3025af679 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Sat, 21 Dec 2019 18:56:30 +0100 Subject: [PATCH 29/29] add test for VersionUtils (#1602) * add test for VersionUtils * add nullability annotation to VersionUtils --- .../tusky/util/VersionUtils.java | 4 ++- .../tusky/util/VersionUtilsTest.kt | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java index 76dcd4510..dceef0f30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky.util; +import androidx.annotation.NonNull; + import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -24,7 +26,7 @@ public class VersionUtils { private int minor; private int patch; - public VersionUtils(String versionString) { + public VersionUtils(@NonNull String versionString) { String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(versionString); diff --git a/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt new file mode 100644 index 000000000..03ab3d947 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt @@ -0,0 +1,36 @@ +package com.keylesspalace.tusky.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class VersionUtilsTest( + private val versionString: String, + private val supportsScheduledToots: Boolean +) { + + companion object { + @JvmStatic + @Parameterized.Parameters + fun data() = listOf( + arrayOf("2.0.0", false), + arrayOf("2a9a0", false), + arrayOf("1.0", false), + arrayOf("error", false), + arrayOf("", false), + arrayOf("2.6.9", false), + arrayOf("2.7.0", true), + arrayOf("2.00008.0", true), + arrayOf("2.7.2 (compatible; Pleroma 1.0.0-1168-ge18c7866-pleroma-dot-site)", true), + arrayOf("3.0.1", true) + ) + } + + @Test + fun testVersionUtils() { + assertEquals(VersionUtils(versionString).supportsScheduledToots(), supportsScheduledToots) + } + +} \ No newline at end of file