diff --git a/java/AndroidManifest.xml b/java/AndroidManifest.xml
index c05b318b9..49855e313 100644
--- a/java/AndroidManifest.xml
+++ b/java/AndroidManifest.xml
@@ -23,8 +23,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/res/drawable-hdpi/ic_notify_dictionary.png b/java/res/drawable-hdpi/ic_notify_dictionary.png
new file mode 100644
index 000000000..55fe4f674
Binary files /dev/null and b/java/res/drawable-hdpi/ic_notify_dictionary.png differ
diff --git a/java/res/drawable-mdpi/ic_notify_dictionary.png b/java/res/drawable-mdpi/ic_notify_dictionary.png
new file mode 100644
index 000000000..fc8701feb
Binary files /dev/null and b/java/res/drawable-mdpi/ic_notify_dictionary.png differ
diff --git a/java/res/drawable-xhdpi/ic_notify_dictionary.png b/java/res/drawable-xhdpi/ic_notify_dictionary.png
new file mode 100644
index 000000000..adf49de60
Binary files /dev/null and b/java/res/drawable-xhdpi/ic_notify_dictionary.png differ
diff --git a/java/res/layout/dictionary_line.xml b/java/res/layout/dictionary_line.xml
new file mode 100644
index 000000000..a8d15ab73
--- /dev/null
+++ b/java/res/layout/dictionary_line.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/res/layout/download_over_metered.xml b/java/res/layout/download_over_metered.xml
new file mode 100644
index 000000000..dcde5edbd
--- /dev/null
+++ b/java/res/layout/download_over_metered.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/res/layout/loading_page.xml b/java/res/layout/loading_page.xml
new file mode 100644
index 000000000..8e816cd15
--- /dev/null
+++ b/java/res/layout/loading_page.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
diff --git a/java/res/raw/empty.dict b/java/res/raw/empty.dict
new file mode 100644
index 000000000..da1bf9666
--- /dev/null
+++ b/java/res/raw/empty.dict
@@ -0,0 +1 @@
+x
\ No newline at end of file
diff --git a/java/res/values-af/strings.xml b/java/res/values-af/strings.xml
index dc6fdf262..07b4503e6 100644
--- a/java/res/values-af/strings.xml
+++ b/java/res/values-af/strings.xml
@@ -170,4 +170,36 @@
"Verstek""Taal en invoer""Kies invoermetode"
+
+ "Woordeboekverskaffer"
+ "Woordeboekverskaffer"
+ "Woordeboek-diens"
+ "Woordeboek se opdateerinligting"
+ "Addisionele woordeboeke"
+ "Woordeboek beskikbaar"
+ "Instellings vir woordeboeke"
+ "Gebruikerwoordeboeke"
+ "Gebruikerwoordeboek"
+ "Woordeboek beskikbaar"
+ "Laai tans af"
+ "Geïnstalleer"
+ "Geïnstalleer, gedeaktiveer"
+ "Kan nie aan woordeboekdiens koppel nie"
+ "Geen woordeboeke beskikbaar nie"
+ "Herlaai"
+ "Laas opgedateer"
+ "Kontroleer vir opdaterings"
+ "Laai tans..."
+ "Hoofwoordeboek"
+ "Kanselleer"
+ "Installeer"
+ "Kanselleer aflaaisel"
+ "Deaktiveer"
+ "Aktiveer"
+ "Vee uit"
+ "Die gekose taal op jou mobiele toestel het \'n beskikbare woordeboek.<br/> Ons beveel aan dat die %1$s-woordeboek <b>afgelaai</b> word om jou tikervaring \'n beter een te maak.<br/> <br/> Dit kan \'n minuut of twee neem om oor 3G af te laai. Heffings mag geld as jy nie \'n <b>onbeperkte dataplan</b>.<br/> het nie As jy onseker oor jou dataplan is, beveel ons aan dat jy \'n WiFi-verbinding soek om outomaties te begin aflaai.<br/> <br/> Wenk: Jy kan woordeboeke aflaai en verwyder deur te gaan na <b>Taal en invoer</b> in die <b>Instellings</b>-kieslys van jou mobiele toestel."
+ "Laai nou af (%1$.1fMB)"
+ "Laai oor Wi-Fi af"
+ "\'n Woordeboek is vir %1$s beskikbaar"
+ "Druk om te hersien en af te laai"
diff --git a/java/res/values-am/strings.xml b/java/res/values-am/strings.xml
index 1907dc962..6f740a9b8 100644
--- a/java/res/values-am/strings.xml
+++ b/java/res/values-am/strings.xml
@@ -170,4 +170,39 @@
"ነባሪ""ቋንቋ እና ግቤት""የግቤት ስልት ይምረጡ"
+
+ "መዝገበ ቃላት አቅራቢ"
+ "መዝገበ ቃላት አቅራቢ"
+ "የመዝገበ ቃላት አገልግሎት"
+ "መዝገበ ቃላት ማዘመኛ መረጃ"
+ "እየታከሉ የሚያድጉ መዝገበ ቃላቶች"
+ "መዝገበ ቃላት ይገኛል"
+ "የመዝገበ ቃላት ቅንብሮች"
+ "የተጠቃሚ መዝገበ ቃላት"
+ "የተጠቃሚ መዝገበ ቃላት"
+ "መዝገበ ቃላት አለ"
+ "በአሁን ጊዜ በማውረድ ላይ"
+ "ተጭኗል"
+ "ተጭኗል፣ ተሰናክሏል"
+ "ወደ መዝገበ ቃላት አገልገሎት በማገናኘት ላይ ችግር"
+ "ምንም መዝገበ ቃላት የሉም"
+ "አድስ"
+ "ለመጨረሻ ጊዜ የዘመነው"
+ "ዝማኔዎችን በመፈተሽ ላይ"
+ "በመጫን ላይ…"
+ "ዋና መዝገበ ቃል"
+ "ተወው"
+ "ጫን"
+ "ማውረድን ተወው"
+ "አሰናክል"
+ "አንቃ"
+ "ሰርዝ"
+
+
+
+
+
+
+ "መዝገበ ቃላት ለ%1$s ይገኛል"
+ "ለመገምገምና ለማውረድ ተጫን"
diff --git a/java/res/values-ar/strings.xml b/java/res/values-ar/strings.xml
index 1268f4395..5668d59ea 100644
--- a/java/res/values-ar/strings.xml
+++ b/java/res/values-ar/strings.xml
@@ -170,4 +170,39 @@
"الافتراضية""اللغة والإدخال""اختيار أسلوب الإدخال"
+
+ "مقدم القاموس"
+ "مقدم القاموس"
+ "خدمة القاموس"
+ "معلومات تحديث القاموس"
+ "القواميس الإضافية"
+ "قاموس متوفر"
+ "إعدادات القواميس"
+ "قواميس المستخدم"
+ "قاموس المستخدم"
+ "القاموس متاح"
+ "يتم حاليًا التنزيل"
+ "مثبت"
+ "مثبت، معطل"
+ "مشكلة في الاتصال بخدمة القاموس"
+ "لا تتوفر أية قواميس"
+ "تحديث"
+ "تاريخ آخر تحديث"
+ "جارٍ البحث عن تحديثات"
+ "تحميل..."
+ "القاموس الرئيسي"
+ "إلغاء"
+ "تثبيت"
+ "إلغاء التنزيل"
+ "تعطيل"
+ "تمكين"
+ "حذف"
+
+
+
+
+
+
+ "هناك قاموس متوفر للغة %1$s"
+ "اضغط للمراجعة والتنزيل"
diff --git a/java/res/values-be/strings.xml b/java/res/values-be/strings.xml
index 6fc28e573..d12e47c46 100644
--- a/java/res/values-be/strings.xml
+++ b/java/res/values-be/strings.xml
@@ -170,4 +170,39 @@
"Па змаўчанні""Мова і ўвод""Выберыце метад уводу"
+
+ "Пастаўшчык слоўніка"
+ "Пастаўшчык слоўніка"
+ "Слоўнік"
+ "Інфармацыя абнаўлення слоўніка"
+ "Дадатковыя слоўнікі"
+ "Даступны слоўнік"
+ "Налады для слоўнікаў"
+ "Карыстальніцкія слоўнікі"
+ "Карыстальніцкі слоўнік"
+ "Даступны слоўнік"
+ "Спампоўваецца зараз"
+ "Усталяваны"
+ "Усталявана, адключана"
+ "Праблема падключэння да слоўніка"
+ "Слоўнікаў няма"
+ "Абнавіць"
+ "Апошняе абнаўленне"
+ "Праверка наяўнасці абнаўленняў"
+ "Загрузка..."
+ "Асноўны слоўнік"
+ "Адмена"
+ "Усталяваць"
+ "Адмяніць спампаванне"
+ "Адключыць"
+ "Уключыць"
+ "Выдаліць"
+
+
+
+
+
+
+ "Слоўнік для мовы \"%1$s\""
+ "Нацiснiце, каб прагледзець i спампаваць"
diff --git a/java/res/values-bg/strings.xml b/java/res/values-bg/strings.xml
index 76bd2d15b..37eaa3346 100644
--- a/java/res/values-bg/strings.xml
+++ b/java/res/values-bg/strings.xml
@@ -170,4 +170,39 @@
"Стандартни""Език и въвеждане""Избор на метод на въвеждане"
+
+ "Доставчик на речника"
+ "Доставчик на речника"
+ "Услуга за речник"
+ "Информация за актуализацията на речниците"
+ "Добавени речници"
+ "Налице е речник"
+ "Настройки за речници"
+ "Потребителски речници"
+ "Потребителски речник"
+ "Речникът е налице"
+ "Изтеглят се понастоящем"
+ "Инсталирано"
+ "Инсталирано, деактивирано"
+ "Има проблем с връзката"
+ "Няма налични речници"
+ "Опресняване"
+ "Последна актуализация:"
+ "Проверява се за актуализации"
+ "Зарежда се..."
+ "Основен речник"
+ "Отказ"
+ "Инсталиране"
+ "Анулиране на изтеглянето"
+ "Деактивиране"
+ "Активиране"
+ "Изтриване"
+
+
+
+
+
+
+ "За %1$s е налице речник"
+ "Натиснете, за да прегледате и изтеглите"
diff --git a/java/res/values-ca/strings.xml b/java/res/values-ca/strings.xml
index ed883334c..c45b07c50 100644
--- a/java/res/values-ca/strings.xml
+++ b/java/res/values-ca/strings.xml
@@ -170,4 +170,36 @@
"Predeterminat""Idioma i introducció""Selecció de mètodes d\'introducció"
+
+ "Proveïdor de diccionaris"
+ "Proveïdor de diccionaris"
+ "Servei de diccionari"
+ "Informació d\'actualització del diccionari"
+ "Diccionaris complementaris"
+ "Diccionari disponible"
+ "Configuració dels diccionaris"
+ "Diccionaris de l\'usuari"
+ "Diccionari de l\'usuari"
+ "Diccionari disponible"
+ "S\'està baixant"
+ "Instal·lat"
+ "Instal·lat, desactivat"
+ "S\'ha produït un problema en connectar al servei de diccionari"
+ "No hi ha cap diccionari disponible."
+ "Actualitza"
+ "Última actualització"
+ "S\'està comprovant si hi ha actualitzacions"
+ "S\'està carregant..."
+ "Diccionari principal"
+ "Cancel·la"
+ "Instal·la"
+ "Cancel·la la baixada"
+ "Desactiva"
+ "Activa"
+ "Suprimeix"
+ "L\'idioma seleccionat al teu dispositiu mòbil té un diccionari disponible.<br/> Et recomanem que <b>baixis</b> el diccionari de %1$s per millorar la teva experiència d\'escriptura.<br/> <br/> La baixada pot trigar un parell de minuts mitjançant 3G. És possible que s\'apliquin càrrecs si no tens un <b>pla de dades il·limitat</b>.<br/> Si no estàs segur de quin pla de dades tens, et recomanem que cerquis una connexió Wi-Fi per començar la baixada automàticament.<br/> <br/> Consell: Pots baixar i eliminar diccionaris si vas a la secció <b>Idioma i entrada</b> del menú <b>Configuració</b> del dispositiu mòbil."
+ "Baixa ara (%1$.1f MB)"
+ "Baixa mitjançant Wi-Fi"
+ "Hi ha un diccionari disponible per a l\'idioma: %1$s"
+ "Prem per opinar i per baixar"
diff --git a/java/res/values-cs/strings.xml b/java/res/values-cs/strings.xml
index b924dad60..503ce1b54 100644
--- a/java/res/values-cs/strings.xml
+++ b/java/res/values-cs/strings.xml
@@ -170,4 +170,39 @@
"Výchozí""Jazyk a zadávání""Výběr metody zadávání dat"
+
+ "Poskytovatel slovníku"
+ "Poskytovatel slovníku"
+ "Služba slovníku"
+ "Informace o aktualizaci slovníku"
+ "Doplňkové slovníky"
+ "Je k dispozici slovník"
+ "Nastavení pro slovníky"
+ "Uživatelské slovníky"
+ "Uživatelský slovník"
+ "K dispozici je slovník"
+ "Aktuální stahování"
+ "Nainstalováno"
+ "Nainstalováno, zakázáno"
+ "Probl. s přip. k sl."
+ "Žádné slovníky"
+ "Aktualizovat"
+ "Poslední aktualizace"
+ "Kontrola aktualizací"
+ "Načítání..."
+ "Hlavní slovník"
+ "Zrušit"
+ "Nainstalovat"
+ "Zrušit stahování"
+ "Zakázat"
+ "Povolit"
+ "Smazat"
+
+
+
+
+
+
+ "Je k dispozici slovník pro jazyk %1$s"
+ "Stisknutím zkontrolujete a stáhnete"
diff --git a/java/res/values-da/strings.xml b/java/res/values-da/strings.xml
index e90344aca..af472f723 100644
--- a/java/res/values-da/strings.xml
+++ b/java/res/values-da/strings.xml
@@ -170,4 +170,36 @@
"Standard""Sprog og input""Vælg inputmetode"
+
+ "Dictionary Provider"
+ "Dictionary Provider"
+ "Ordbogstjeneste"
+ "Opdateringsoplysninger for ordbøger"
+ "Tillægsordbøger"
+ "Der er en tilgængelig ordbog"
+ "Indstillinger for ordbøger"
+ "Brugerordbøger"
+ "Brugerordbog"
+ "Ordbog er tilgængelig"
+ "Downloader i øjeblikket"
+ "Installeret"
+ "Installeret, deaktiveret"
+ "Uden ordbogstjeneste"
+ "Ingen tilg. ordbøger"
+ "Opdater"
+ "Sidst opdateret"
+ "Søger efter opdateringer"
+ "Indlæser..."
+ "Hovedordbog"
+ "Annuller"
+ "Installer"
+ "Annuller download"
+ "Deaktiver"
+ "Aktivér"
+ "Slet"
+ "Det valgte sprog på din mobilenhed har en tilgængelig ordbog.<br/> Vi anbefaler, at du <b>downloader</b> %1$s-ordbogen for at forbedre din skriveoplevelse.<br/> <br/> Downloaden kan tage 1-2 minutter via 3G. Der bliver muligvis opkrævet afgifter, hvis du ikke har et <b>ubegrænset dataabonnement</b>.<br/>. Hvis du ikke er sikker på, hvilket dataabonnemt du har, anbefaler vi, at du finder en Wi-Fi-forbindelse for at starte automatisk download.<br/> <br/>Tip: Du kan downloade og fjerne ordbøger ved at gå til <b>Sprog og input </b> i menuen <b>Indstillinger</b> på din mobilenhed."
+ "Download nu (%1$.1f MB)"
+ "Download via Wi-Fi"
+ "Der er en tilgængelig ordbog for %1$s"
+ "Tryk for at gennemgå og downloade"
diff --git a/java/res/values-de/strings.xml b/java/res/values-de/strings.xml
index ce620f0c3..7ef3d0043 100644
--- a/java/res/values-de/strings.xml
+++ b/java/res/values-de/strings.xml
@@ -170,4 +170,36 @@
"Standard""Sprache & Eingabe""Eingabemethode wählen"
+
+ "Wörterbuchbereitstellung"
+ "Wörterbuchbereitstellung"
+ "Wörterbuch"
+ "Update-Informationen für Wörterbuch"
+ "Erweiterte Wörterbücher"
+ "Wörterbuch verfügbar"
+ "Einstellungen für Wörterbücher"
+ "Meine Wörterbücher"
+ "Mein Wörterbuch"
+ "Wörterbuch verfügbar"
+ "Aktuelle Downloads"
+ "Installiert"
+ "Installiert, deaktiviert"
+ "Kein Wörterbuchdienst"
+ "Keine Wörterbücher"
+ "Aktualisieren"
+ "Zuletzt aktualisiert"
+ "Suche nach Updates..."
+ "Wird geladen..."
+ "Allgemeines Wörterbuch"
+ "Abbrechen"
+ "Installieren"
+ "Download abbrechen"
+ "Deaktivieren"
+ "Aktivieren"
+ "Löschen"
+ "Für die auf dem Mobilgerät ausgewählte Sprache ist ein Wörterbuch verfügbar.<br/> <b>Laden Sie das %1$s-Wörterbuch herunter</b> und verbessern Sie Ihre Eingabeerfahrung.<br/> <br/>Der Download über 3G kann ein bis zwei Minuten dauern. Falls Sie keine <b>Datenflatrate</b> haben, fallen eventuell Gebühren an.<br/> Sollten Sie sich nicht sicher sein, welchen Datentarif Sie haben, suchen Sie eine WLAN-Verbindung, um den Download automatisch zu starten.<br/> <br/>Tipp: Im Menü <b>Einstellungen</b> Ihres Mobilgeräts können Sie unter <b>Sprache & Eingabe</b> Wörterbücher herunterladen und entfernen."
+ "Jetzt herunterladen (%1$.1f MB)"
+ "Über WLAN herunterladen"
+ "Es ist ein Wörterbuch für %1$s verfügbar."
+ "Zum Lesen und Herunterladen drücken"
diff --git a/java/res/values-el/strings.xml b/java/res/values-el/strings.xml
index af231b401..74ffcf97c 100644
--- a/java/res/values-el/strings.xml
+++ b/java/res/values-el/strings.xml
@@ -170,4 +170,39 @@
"Προεπιλογή""Γλώσσα και εισαγωγή""Επιλογή μεθόδου εισαγωγής"
+
+ "Παροχέας λεξικού"
+ "Παροχέας λεξικού"
+ "Υπηρεσία λεξικού"
+ "Ενημέρωση πληροφοριών λεξικού"
+ "Πρόσθετα λεξικά"
+ "Διαθέσιμο λεξικό"
+ "Ρυθμίσεις για λεξικά"
+ "Λεξικά χρήστη"
+ "Λεξικό χρήστη"
+ "Διαθέσιμο λεξικό"
+ "Λήψη αυτήν τη στιγμή"
+ "Εγκατεστημένο"
+ "Εγκαταστάθηκε, απενεργοποιήθηκε"
+ "Πρόβλ.σύνδ.στο λεξ."
+ "Δεν υπάρχουν διαθέσιμα λεξικά"
+ "Ανανέωση"
+ "Τελευταία ενημέρωση"
+ "Έλεγχος για ενημερώσεις"
+ "Φόρτωση..."
+ "Κύριο λεξικό"
+ "Ακύρωση"
+ "Εγκατάσταση"
+ "Ακύρωση λήψης"
+ "Απενεργοποίηση"
+ "Ενεργοποίηση"
+ "Διαγραφή"
+
+
+
+
+
+
+ "Υπάρχει διαθέσιμο λεξικό για τα %1$s"
+ "Πατήστε για έλεγχο και λήψη"
diff --git a/java/res/values-en-rGB/strings.xml b/java/res/values-en-rGB/strings.xml
index 9e7d64f73..3bb7babc8 100644
--- a/java/res/values-en-rGB/strings.xml
+++ b/java/res/values-en-rGB/strings.xml
@@ -170,4 +170,36 @@
"Default""Language & input""Choose input method"
+
+ "Dictionary Provider"
+ "Dictionary Provider"
+ "Dictionary Service"
+ "Dictionary update information"
+ "Add-on dictionaries"
+ "Dictionary available"
+ "Settings for dictionaries"
+ "User dictionaries"
+ "User dictionary"
+ "Dictionary available"
+ "Currently downloading"
+ "Installed"
+ "Installed, disabled"
+ "Problem while connecting to dictionary service"
+ "No dictionaries available"
+ "Refresh"
+ "Last updated"
+ "Checking for updates"
+ "Loading..."
+ "Main dictionary"
+ "Cancel"
+ "Install"
+ "Cancel download"
+ "Disable"
+ "Enable"
+ "Delete"
+ "The selected language on your mobile device has an available dictionary.<br/> We recommend <b>downloading</b> the %1$s dictionary to improve your typing experience.<br/> <br/> The download could take a minute or two over 3G. Charges may apply if you don\'t have an <b>unlimited data plan</b>.<br/> If you are not sure which data plan you have, we recommend finding a Wifi connection to start the download automatically.<br/> <br/> Tip: You can download and remove dictionaries by going to <b>Language & input</b> in the <b>Settings</b> menu of your mobile device."
+ "Download now (%1$.1fMB)"
+ "Download over Wifi"
+ "A dictionary is available for %1$s"
+ "Press to review and download"
diff --git a/java/res/values-es-rUS/strings.xml b/java/res/values-es-rUS/strings.xml
index 50b2461e5..0d0aa1430 100644
--- a/java/res/values-es-rUS/strings.xml
+++ b/java/res/values-es-rUS/strings.xml
@@ -170,4 +170,36 @@
"Predeterminado""Teclado e idioma""Seleccionar método de entrada"
+
+ "Proveedor de diccionarios"
+ "Proveedor de diccionarios"
+ "Servicio de diccionarios"
+ "Información acerca de la actualización del diccionario"
+ "Diccionarios complementarios"
+ "Diccionario disponible"
+ "Configuración de los diccionarios"
+ "Diccionarios del usuario"
+ "Diccionario del usuario"
+ "Diccionario disponible"
+ "Descarga en curso"
+ "Instalado"
+ "Instalado, inhabilitado"
+ "Err. conex. con dic."
+ "No hay diccionarios."
+ "Actualizar"
+ "Última actualización"
+ "Buscando las actualizaciones"
+ "Cargando…"
+ "Diccionario principal"
+ "Cancelar"
+ "Instalar"
+ "Cancelar la descarga"
+ "Inhabilitar"
+ "Activar"
+ "Eliminar"
+ "Hay un diccionario disponible para el idioma seleccionado en tu dispositivo móvil.<br/> Te recomendamos que <b>descargues</b> el diccionario de %1$s para mejorar tu experiencia de escritura.<br/> <br/> La descarga puede tardar unos minutos en redes 3G. Si no tienes un <b>plan de datos ilimitado</b>, es posible que se apliquen cargos.<br/> Si no conoces las características de tu plan de datos, te recomendamos que uses una conexión Wi-Fi para iniciar la descarga automáticamente.<br/> <br/> Sugerencia: Puedes descargar y eliminar diccionarios en la sección <b>Idioma e introducción de texto</b> del menú <b>Ajustes</b> del dispositivo móvil."
+ "Descargar ahora ( %1$.1f MB)"
+ "Descargar mediante Wi-Fi"
+ "Hay un diccionario disponible de %1$s."
+ "Pulsa para consultar y descargar"
diff --git a/java/res/values-es/strings.xml b/java/res/values-es/strings.xml
index 42427fe5c..95846efa7 100644
--- a/java/res/values-es/strings.xml
+++ b/java/res/values-es/strings.xml
@@ -170,4 +170,36 @@
"Predeterminado""Idioma y entrada de texto""Selecciona un método de entrada"
+
+ "Proveedor del diccionario"
+ "Proveedor del diccionario"
+ "Servicio de diccionario"
+ "Información de actualización del diccionario"
+ "Diccionarios complementarios"
+ "Diccionario disponible"
+ "Ajustes de diccionarios"
+ "Diccionarios del usuario"
+ "Diccionario del usuario"
+ "Hay un diccionario disponible"
+ "Descargas en curso"
+ "Instalado"
+ "Instalado (inhabilitado)"
+ "Error al conectar"
+ "No hay diccionarios"
+ "Actualizar"
+ "Última actualización"
+ "Buscando actualizaciones"
+ "Cargando..."
+ "Diccionario principal"
+ "Cancelar"
+ "Instalar"
+ "Cancelar descarga"
+ "Inhabilitar"
+ "Habilitar"
+ "Eliminar"
+ "Hay un diccionario disponible para el idioma seleccionado en tu dispositivo móvil.<br/> Te recomendamos que <b>descargues</b> el diccionario de %1$s para mejorar tu experiencia de escritura.<br/> <br/> La descarga puede tardar unos minutos en redes 3G. Si no tienes un <b>plan de datos ilimitado</b>, es posible que se apliquen cargos.<br/> Si no conoces las características de tu plan de datos, te recomendamos que uses una conexión Wi-Fi para iniciar la descarga automáticamente.<br/> <br/> Sugerencia: puedes descargar y eliminar diccionarios en la sección <b>Idioma e introducción de texto</b> del menú <b>Ajustes</b> del dispositivo móvil."
+ "Descargar ahora (%1$.1f MB)"
+ "Descargar mediante Wi-Fi"
+ "Hay un diccionario disponible de %1$s"
+ "Pulsa para comprobar y descargar"
diff --git a/java/res/values-et/strings.xml b/java/res/values-et/strings.xml
index 51eb47bcd..0697cab11 100644
--- a/java/res/values-et/strings.xml
+++ b/java/res/values-et/strings.xml
@@ -170,4 +170,36 @@
"Vaikeväärtus""Keeled ja sisestamine""Valige sisestusmeetod"
+
+ "Sõnastikupakkuja"
+ "Sõnastikupakkuja"
+ "Sõnaraamatuteenus"
+ "Sõnastiku värskendamisteave"
+ "Pistiksõnastikud"
+ "Sõnastik on saadaval"
+ "Sõnastike seaded"
+ "Kasutajasõnastikud"
+ "Kasutajasõnastik"
+ "Sõnastik on saadaval"
+ "Praegu allalaadimisel"
+ "Installitud"
+ "Installitud, keelatud"
+ "Tõrge sõnast. ühend."
+ "Sõnastikke pole"
+ "Värskenda"
+ "Viimati värskendatud"
+ "Värskenduste otsimine"
+ "Laadimine ..."
+ "Põhisõnastik"
+ "Tühista"
+ "Installi"
+ "Tühista allalaadimine"
+ "Keela"
+ "Luba"
+ "Kustuta"
+ "Mobiilseadmes valitud keelele on saadaval sõnastik.<br/> Teksti sisestamiseks soovitame <b>alla laadida</b> sõnastiku %1$s.<br/> <br/> 3G kaudu allalaadimisele võib kuluda minut või paar. Kehtida võivad tasud, kui te ei kasuta <b>piiramatut andmepaketti</b>.<br/> Kui te ei tea, millist andmepaketti kasutate, soovitame allalaadimise automaatseks käivitamiseks leida WiFi-ühenduse.<br/> <br/> Nõuanne: sõnastikke saate alla laadida ja eemaldada, tehes valiku <b>Keele & sisend</b> mobiilseadme menüüs <b>Seaded</b>."
+ "Laadi kohe alla (%1$.1f MB)"
+ "Laadi alla WiFi kaudu"
+ "Sõnastik on %1$s keele jaoks saadaval"
+ "Vajutage ülevaatamiseks ja allalaadimiseks"
diff --git a/java/res/values-fa/strings.xml b/java/res/values-fa/strings.xml
index 8f71f1912..ecc7e28f5 100644
--- a/java/res/values-fa/strings.xml
+++ b/java/res/values-fa/strings.xml
@@ -174,4 +174,39 @@
"پیشفرض""زبان و ورودی""انتخاب روش ورودی"
+
+ "ارائهدهنده فرهنگ لغت"
+ "ارائه دهنده فرهنگ لغت"
+ "سرویس فرهنگ لغت"
+ "اطلاعات بهروزرسانی فرهنگ لغت"
+ "فرهنگهای لغت افزودنی"
+ "فرهنگ لغت در دسترس"
+ "تنظیمات برای فرهنگ لغتها"
+ "فرهنگهای لغت کاربر"
+ "فرهنگ لغت کاربر"
+ "فرهنگ لغت موجود است"
+ "موارد در حال دانلود کنونی"
+ "نصب شد"
+ "نصب شد، غیرفعال شد"
+ "مشکل اتصال به سرویس فرهنگ لغت"
+ "هیچ فرهنگ لغتی موجود نیست"
+ "بازخوانی"
+ "تاریخ آخرین بهروزرسانی"
+ "درحال بررسی بهروزرسانیها"
+ "در حال بارگیری..."
+ "فرهنگ لغت اصلی"
+ "لغو"
+ "نصب"
+ "لغو دانلود"
+ "غیرفعال کردن"
+ "فعال کردن"
+ "حذف"
+
+
+
+
+
+
+ "یک فرهنگ لغت برای %1$s در دسترس است"
+ "برای مرور و دانلود فشار دهید"
diff --git a/java/res/values-fi/strings.xml b/java/res/values-fi/strings.xml
index 9fe8f1b0b..624e4e6de 100644
--- a/java/res/values-fi/strings.xml
+++ b/java/res/values-fi/strings.xml
@@ -170,4 +170,39 @@
"Oletusarvot""Kieli ja syöttötapa""Valitse syöttötapa"
+
+ "Sanakirjan tarjoaja"
+ "Sanakirjan tarjoaja"
+ "Sanakirjapalvelu"
+ "Sanakirjan päivitystiedot"
+ "Sanakirjalisäosat"
+ "Sanakirja saatavilla"
+ "Sanakirjojen asetukset"
+ "Käyttäjän sanakirjat"
+ "Käyttäjän sanakirja"
+ "Sanakirja saatavilla"
+ "Ladataan parhaillaan"
+ "Asennettu"
+ "Asennettu, poistettu käytöstä"
+ "Ongelma yhd. sanak.palveluun"
+ "Ei sanakirj. saatav."
+ "Päivitä"
+ "Viimeksi päivitetty"
+ "Tarkistetaan päivityksiä"
+ "Ladataan..."
+ "Pääsanakirja"
+ "Peruuta"
+ "Asenna"
+ "Peruuta lataus"
+ "Poista käytöstä"
+ "Ota käyttöön"
+ "Poista"
+
+
+
+
+
+
+ "Kielen %1$s sanakirja on saatavilla"
+ "Paina tätä, jos haluat tarkastella kohdetta tai ladata sen"
diff --git a/java/res/values-fr/strings.xml b/java/res/values-fr/strings.xml
index 8b2287415..7788e5cde 100644
--- a/java/res/values-fr/strings.xml
+++ b/java/res/values-fr/strings.xml
@@ -170,4 +170,36 @@
"Par défaut""Langue et saisie""Sélectionnez le mode de saisie"
+
+ "Fournisseur de dictionnaires"
+ "Fournisseur de dictionnaires"
+ "Service de dictionnaires"
+ "Informations relatives à la mise à jour des dictionnaires"
+ "Dictionnaires complémentaires"
+ "Dictionnaire disponible"
+ "Paramètres des dictionnaires"
+ "Dictionnaires personnels"
+ "Dictionnaire personnel"
+ "Dictionnaire disponible"
+ "En cours de téléchargement…"
+ "Installé"
+ "Installé, désactivé"
+ "Pas de service dictionnaire."
+ "Aucun dictionnaire."
+ "Actualiser"
+ "Dernière mise à jour"
+ "Recherche de mises à jour en cours…"
+ "Chargement…"
+ "Dictionnaire principal"
+ "Annuler"
+ "Installer"
+ "Annuler le téléchargement"
+ "Désactiver"
+ "Activer"
+ "Supprimer"
+ "Un dictionnaire est disponible pour la langue sélectionnée sur votre appareil mobile.<br/> Nous vous invitons à <b>télécharger</b> le dictionnaire %1$s pour faciliter votre saisie.<br/> <br/> Le téléchargement peut prendre une à deux minutes via une connexion 3G. Des frais peuvent s\'appliquer si vous ne disposez pas d\'un <b>forfait Internet illimité</b>.<br/> Si vous n\'êtes pas sûr de votre forfait, nous vous conseillons d\'utiliser une connexion Wi-Fi pour lancer automatiquement le téléchargement.<br/> <br/> Astuce : Vous pouvez télécharger et supprimer des dictionnaires dans <b>Langue et saisie</b> du menu <b>Paramètres</b> de votre appareil mobile."
+ "Télécharger maintenant (%1$.1f Mo)"
+ "Télécharger via Wi-Fi"
+ "Un dictionnaire est disponible en %1$s"
+ "Appuyez ici pour consulter et télécharger le dictionnaire."
diff --git a/java/res/values-hi/strings.xml b/java/res/values-hi/strings.xml
index 77081bd99..4676034b0 100644
--- a/java/res/values-hi/strings.xml
+++ b/java/res/values-hi/strings.xml
@@ -170,4 +170,39 @@
"डिफ़ॉल्ट""भाषा और इनपुट""इनपुट पद्धति चुनें"
+
+ "डिक्शनरी प्रदाता"
+ "डिक्शनरी प्रदाता"
+ "डिक्शनरी सेवा"
+ "डिक्शनरी अपडेट जानकारी"
+ "एड-ऑन डिक्शनरी"
+ "डिक्शनरी उपलब्ध"
+ "डिक्शनरी के लिए सेटिंग"
+ "उपयोगकर्ता डिक्शनरी"
+ "उपयोगकर्ता डिक्शनरी"
+ "डिक्शनरी उपलब्ध"
+ "वर्तमान में डाउनलोड हो रहा है"
+ "इंस्टॉल है"
+ "इंस्टॉल है, अक्षम है"
+ "डिक्श. सेवा से कनेक्ट करने में समस्या"
+ "कोई डिक्शनरी उपलब्ध नहीं"
+ "रीफ़्रेश करें"
+ "अंतिम अपडेट"
+ "अपडेट देखे जा रहे हैं"
+ "लोड हो रही है..."
+ "मुख्य डिक्शनरी"
+ "रद्द करें"
+ "इंस्टॉल करें"
+ "डाउनलोड रद्द करें"
+ "अक्षम करें"
+ "सक्षम करें"
+ "हटाएं"
+
+
+
+
+
+
+ "%1$s के लिए डिक्शनरी उपलब्ध है"
+ "समीक्षा और डाउनलोड करने के लिए दबाएं"
diff --git a/java/res/values-hr/strings.xml b/java/res/values-hr/strings.xml
index 0710d307e..184535689 100644
--- a/java/res/values-hr/strings.xml
+++ b/java/res/values-hr/strings.xml
@@ -170,4 +170,39 @@
"Zadano""Jezik i unos""Odabir načina unosa"
+
+ "Davatelj rječnika"
+ "Davatelj rječnika"
+ "Usluga rječnika"
+ "Ažurirane informacije rječnika"
+ "Rječnici - dodaci"
+ "Dostupan je rječnik"
+ "Postavke za rječnike"
+ "Korisnički rječnici"
+ "Korisnički rječnik"
+ "Rječnik je dostupan"
+ "Trenutačno u preuzimanju"
+ "Instalirano"
+ "Instalirano, onemogućeno"
+ "Nema usluge rječnika"
+ "Rječnici nedostupni"
+ "Osvježi"
+ "Zadnje ažuriranje"
+ "Provjera ažuriranja"
+ "Učitavanje..."
+ "Glavni rječnik"
+ "Odustani"
+ "Instaliraj"
+ "Otkaži preuzimanje"
+ "Onemogući"
+ "Omogući"
+ "Izbriši"
+
+
+
+
+
+
+ "Dostupan je rječnik za %1$s jezik"
+ "Pritisnite za pregled i preuzimanje"
diff --git a/java/res/values-hu/strings.xml b/java/res/values-hu/strings.xml
index e9c7aa018..55cd3c235 100644
--- a/java/res/values-hu/strings.xml
+++ b/java/res/values-hu/strings.xml
@@ -170,4 +170,39 @@
"Alapértelmezett""Nyelv és bevitel""Beviteli mód kiválasztása"
+
+ "Szótárszolgáltató"
+ "Szótárszolgáltató"
+ "Szótár szolgáltatás"
+ "Szótárfrissítéssel kapcsolatos információk"
+ "Bővítmények: szótárak"
+ "Van rendelkezésre álló szótár"
+ "Szótárak beállításai"
+ "Felhasználói szótárak"
+ "Felhasználói szótár"
+ "Van rendelkezésre álló szótár"
+ "Jelenlegi letöltések"
+ "Telepítve"
+ "Telepítve, kikapcsolva"
+ "Csatlakozási hiba"
+ "Nincs szótár"
+ "Frissítés"
+ "Utoljára frissítve"
+ "Frissítések keresése"
+ "Betöltés..."
+ "Fő szótár"
+ "Mégse"
+ "Telepítés"
+ "Letöltés megszakítása"
+ "Kikapcsolás"
+ "Bekapcsolás"
+ "Törlés"
+
+
+
+
+
+
+ "%1$s nyelvhez van rendelkezésre álló szótár"
+ "Nyomja meg az áttekintéshez és letöltéshez"
diff --git a/java/res/values-in/strings.xml b/java/res/values-in/strings.xml
index 5efe8dde2..f9b028c05 100644
--- a/java/res/values-in/strings.xml
+++ b/java/res/values-in/strings.xml
@@ -170,4 +170,39 @@
"Default""Bahasa & masukan""Pilih metode masukan"
+
+ "Penyedia Kamus"
+ "Penyedia Kamus"
+ "Layanan Kamus"
+ "Informasi pembaruan kamus"
+ "Kamus pengaya"
+ "Kamus tersedia"
+ "Setelan untuk kamus"
+ "Kamus pengguna"
+ "Kamus pengguna"
+ "Kamus yang tersedia"
+ "Saat ini sedang mengunduh"
+ "Terpasang"
+ "Terpasang, dinonaktifkan"
+ "Masalah koneksi ke layanan kamus"
+ "Tidak tersedia kamus"
+ "Segarkan"
+ "Terakhir diperbarui"
+ "Memeriksa pembaruan"
+ "Memuat..."
+ "Kamus utama"
+ "Batal"
+ "Pasang"
+ "Batalkan unduhan"
+ "Nonaktifkan"
+ "Aktifkan"
+ "Hapus"
+
+
+
+
+
+
+ "Kamus tersedia untuk bahasa %1$s"
+ "Tekan untuk meninjau dan mengunduh"
diff --git a/java/res/values-it/strings.xml b/java/res/values-it/strings.xml
index 0a62f8ad4..52a99c479 100644
--- a/java/res/values-it/strings.xml
+++ b/java/res/values-it/strings.xml
@@ -170,4 +170,36 @@
"Predefinito""Lingua e input""Scegli il metodo di immissione"
+
+ "Dictionary Provider"
+ "Dictionary Provider"
+ "Servizio dizionario"
+ "Informazioni aggiornate dizionari"
+ "Dizionari aggiuntivi"
+ "Dizionario disponibile"
+ "Impostazioni per dizionari"
+ "Dizionari utente"
+ "Dizionario utente"
+ "Dizionario disponibile"
+ "In fase di download"
+ "Installato"
+ "Installato, disabilitato"
+ "Problema conness. dizion."
+ "Nessun dizionario"
+ "Aggiorna"
+ "Ultimo aggiornamento"
+ "Verifica disponibilità aggiornamenti"
+ "Caricamento..."
+ "Dizionario principale"
+ "Annulla"
+ "Installa"
+ "Annulla download"
+ "Disattiva"
+ "Abilita"
+ "Elimina"
+ "La lingua selezionata sul tuo dispositivo mobile ha un dizionario disponibile.<br/> Ti consigliamo di <b>scaricare</b> il dizionario di %1$s per migliorare l\'esperienza di digitazione.<br/> <br/> Il download potrebbe richiedere un paio di minuti su reti 3G. Potrebbero essere applicate delle tariffe se non disponi di un <b>piano dati illimitato</b>.<br/> Se non sai bene quale piano dati è in uso, ti consigliamo di trovare una connessione Wi-Fi per avviare il download automaticamente.<br/> <br/> Suggerimento. Puoi scaricare e rimuovere i dizionari passando a <b>Lingue e immissione</b> nel menu <b>Impostazioni</b> del tuo dispositivo mobile."
+ "Scarica ora (%1$.1f MB)"
+ "Scarica tramite Wi-Fi"
+ "È disponibile un dizionario per %1$s"
+ "Premi per esaminare e scaricare"
diff --git a/java/res/values-iw/strings.xml b/java/res/values-iw/strings.xml
index 33d9fc57e..9e5411bfb 100644
--- a/java/res/values-iw/strings.xml
+++ b/java/res/values-iw/strings.xml
@@ -170,4 +170,36 @@
"ברירת מחדל""שפה וקלט""בחירת שיטת קלט"
+
+ "ספק המילון"
+ "ספק המילון"
+ "שירות מילון"
+ "פרטי עדכון מילון"
+ "הוספת מילונים"
+ "מילון זמין"
+ "הגדרות עבור מילונים"
+ "מילוני משתמש"
+ "מילון משתמש"
+ "מילון זמין"
+ "מוריד כעת"
+ "מותקן"
+ "מותקן, מושבת"
+ "בעיה בהתחברות לשירות המילון"
+ "אין מילונים זמינים"
+ "רענן"
+ "עודכן לאחרונה"
+ "מחפש עדכונים"
+ "טוען..."
+ "מילון ראשי"
+ "ביטול"
+ "התקן"
+ "בטל הורדה"
+ "השבת"
+ "הפוך לפעיל"
+ "מחק"
+ "לשפה הנבחרת במכשיר הנייד שלך יש מילון זמין.<br/> אנו ממליצים <b>להוריד</b> את המילון ב%1$s כדי לשפר את חוויית ההקלדה.<br/> <br/> ההורדה עשויה לארוך דקה או שתיים ב-3G. ייתכן שתחויב אם אין לך <b>תוכנית נתונים בלתי מוגבלת</b>.<br/> אם אינך בטוח איזו תוכנית נתונים יש לך, אנו ממליצים לחפש חיבור Wi-Fi כדי להתחיל בהורדה באופן אוטומטי.<br/> <br/> טיפ: ניתן להוריד ולהסיר מילונים ב<b>שפה וקלט</b> בתפריט <b>הגדרות</b> של המכשיר הנייד שלך."
+ "הורד עכשיו (%1$.1fMB)"
+ "הורד באמצעות Wi-Fi"
+ "יש מילון זמין עבור %1$s"
+ "לחץ כדי לעיין ולהוריד"
diff --git a/java/res/values-ja/strings.xml b/java/res/values-ja/strings.xml
index 25b37a4b4..5464d6058 100644
--- a/java/res/values-ja/strings.xml
+++ b/java/res/values-ja/strings.xml
@@ -170,4 +170,39 @@
"デフォルト""言語と入力""入力方法の選択"
+
+ "辞書提供元"
+ "辞書提供元"
+ "辞書"
+ "辞書のアップデート情報"
+ "アドオン辞書"
+ "辞書を利用できます"
+ "辞書の設定"
+ "単語リスト"
+ "単語リスト"
+ "辞書を利用できます"
+ "ダウンロードしています"
+ "インストール済み"
+ "インストール済み、無効"
+ "辞書に接続できません"
+ "辞書はありません"
+ "更新"
+ "最終更新日"
+ "アップデートを確認しています"
+ "読み込み中..."
+ "メイン辞書"
+ "キャンセル"
+ "インストール"
+ "ダウンロードをキャンセル"
+ "無効にする"
+ "有効にする"
+ "削除"
+
+
+
+
+
+
+ "%1$sの辞書を利用できます"
+ "押すと確認/ダウンロードできます"
diff --git a/java/res/values-ko/strings.xml b/java/res/values-ko/strings.xml
index 3b0228a88..ca1c2ea6e 100644
--- a/java/res/values-ko/strings.xml
+++ b/java/res/values-ko/strings.xml
@@ -170,4 +170,39 @@
"기본값""언어 및 키보드""입력 방법 선택"
+
+ "사전 제공업체"
+ "사전 제공업체"
+ "사전 서비스"
+ "사전 업데이트 정보"
+ "사전 추가"
+ "사전 사용 가능"
+ "사전 설정"
+ "사용자 사전"
+ "사용자 사전"
+ "사전 사용 가능"
+ "현재 다운로드 중"
+ "설치됨"
+ "설치 완료되었으나 사용 중지됨"
+ "사전 서비스에 연결하는 동안 문제가 발생했습니다."
+ "사용할 수 있는 사전이 없습니다."
+ "새로고침"
+ "최근 업데이트"
+ "업데이트를 확인하는 중"
+ "로드 중..."
+ "기본 사전"
+ "취소"
+ "설치"
+ "다운로드 취소"
+ "사용 중지"
+ "사용"
+ "삭제"
+
+
+
+
+
+
+ "%1$s 사전을 사용할 수 있습니다."
+ "검토하고 다운로드하려면 누르세요."
diff --git a/java/res/values-lt/strings.xml b/java/res/values-lt/strings.xml
index 9bfffbc52..49d9aec2a 100644
--- a/java/res/values-lt/strings.xml
+++ b/java/res/values-lt/strings.xml
@@ -170,4 +170,39 @@
"Numatytieji""Kalba ir įvestis""Pasirinkite įvesties metodą"
+
+ "Žodyno teikėjas"
+ "Žodyno teikėjas"
+ "Žodyno paslauga"
+ "Žodyno atnaujinimo informacija"
+ "Papildomi žodynai"
+ "Žodynas galimas"
+ "Žodynų nustatymai"
+ "Naudotojo žodynai"
+ "Naudotojo žodynas"
+ "Žodynas galimas"
+ "Šiuo metu atsisiunčiama"
+ "Įdiegta"
+ "Įdiegta, neleidžiama"
+ "Prisijungimo prie žodyno paslaugos problema"
+ "Nėra galimų žodynų"
+ "Atnaujinti"
+ "Paskutinį kartą atnaujinta"
+ "Ieškoma naujinių"
+ "Įkeliama..."
+ "Pagrindinis žodynas"
+ "Atšaukti"
+ "Įdiegti"
+ "Atšaukti atsisiuntimą"
+ "Neleisti"
+ "Įgalinti"
+ "Ištrinti"
+
+
+
+
+
+
+ "Galimas %1$s žodynas"
+ "Paspauskite, kad peržiūrėtumėte ir atsisiųstumėte"
diff --git a/java/res/values-lv/strings.xml b/java/res/values-lv/strings.xml
index 5dcaa0100..4d6fb3c01 100644
--- a/java/res/values-lv/strings.xml
+++ b/java/res/values-lv/strings.xml
@@ -170,4 +170,36 @@
"Noklusējums""Valoda un ievade""Ievades metodes izvēle"
+
+ "Vārdnīcas nodrošinātājs"
+ "Vārdnīcas nodrošinātājs"
+ "Vārdnīcas pakalpojums"
+ "Vārdnīcas atjauninājuma informācija"
+ "Pievienojumvārdnīcas"
+ "Vārdnīca ir pieejama"
+ "Vārdnīcu iestatījumi"
+ "Lietotāja vārdnīcas"
+ "Lietotāja vārdnīca"
+ "Vārdnīca ir pieejama."
+ "Notiek lejupielāde."
+ "Instalēta"
+ "Instalēta, atspējota"
+ "Problēma, savien. ar vārdn. pak."
+ "Vārdn. nav pieejamas"
+ "Atsvaidzināt"
+ "Pēdējo reizi atjaunināts"
+ "Notiek pārbaude, vai ir pieejami atjauninājumi."
+ "Notiek ielāde..."
+ "Galvenā vārdnīca"
+ "Atcelt"
+ "Instalēt"
+ "Atcelt lejupielādi"
+ "Atspējot"
+ "Iespējot"
+ "Dzēst"
+ "Mobilajā ierīcē atlasītajai valodai ir pieejama vārdnīca.<br/> Ieteicams <b>lejupielādēt</b> %1$s vārdnīcu, lai uzlabotu rakstīšanas iespējas.<br/> <br/> Lejupielāde, izmantojot 3G, aizņems dažas minūtes. Ja nelietojat <b>neierobežotu datu plānu</b>, var tikt piemērota maksa.<br/> Ja nezināt, kādu datu plānu lietojat, atrodiet Wi-Fi savienojumu, lai automātiski sāktu lejupielādi.<br/> <br/> Padoms: vārdnīcas var lejupielādēt un noņemt mobilās ierīces izvēlnes <b>Iestatījumi</b> sadaļā <b>Valodas ievade</b>."
+ "Lejupielādēt tūlīt (%1$.1f MB)"
+ "Lejupielādēt, izmantojot Wi-Fi"
+ "Ir pieejama vārdnīca šādai valodai: %1$s"
+ "Nospiediet, lai pārskatītu un lejupielādētu"
diff --git a/java/res/values-ms/strings.xml b/java/res/values-ms/strings.xml
index 4866fed8f..7bcc28688 100644
--- a/java/res/values-ms/strings.xml
+++ b/java/res/values-ms/strings.xml
@@ -170,4 +170,39 @@
"Lalai""Bahasa & input""Pilih kaedah input"
+
+ "Pembekal Kamus"
+ "Pembekal Kamus"
+ "Perkhidmatan Kamus"
+ "Maklumat kemas kini kamus"
+ "Kamus tambahan"
+ "Kamus tersedia"
+ "Tetapan untuk kamus"
+ "Kamus pengguna"
+ "Kamus pengguna"
+ "Kamus tersedia"
+ "Sedang memuat turun"
+ "Dipasang"
+ "Dipasang, dilumpuhkan"
+ "Masalah menyambung kepada perkhidmatan kamus"
+ "Tiada kamus tersedia"
+ "Muat semula"
+ "Kali terakhir dikemas kini"
+ "Menyemak kemas kini"
+ "Memuatkan..."
+ "Kamus utama"
+ "Batal"
+ "Pasang"
+ "Batalkan muat turun"
+ "Lumpuhkan"
+ "Dayakan"
+ "Padam"
+
+
+
+
+
+
+ "Kamus tersedia untuk %1$s"
+ "Tekan untuk mengulas dan memuat turun"
diff --git a/java/res/values-nb/strings.xml b/java/res/values-nb/strings.xml
index 32793ef8c..2e1d44ebd 100644
--- a/java/res/values-nb/strings.xml
+++ b/java/res/values-nb/strings.xml
@@ -170,4 +170,36 @@
"Standard""Språk og inndata""Velg inndatametode"
+
+ "Ordlisteleverandør"
+ "Ordlisteleverandør"
+ "Ordboktjeneste"
+ "Oppdateringsinformasjon for ordliste"
+ "Tilleggsordlister"
+ "Ordliste er tilgjengelig"
+ "Innstillinger for ordlister"
+ "Brukerordlister"
+ "Brukerordliste"
+ "Ordliste er tilgjengelig"
+ "Laster ned nå"
+ "Installert"
+ "Installert, deaktivert"
+ "Kan ikke koble til ordlistetjenesten"
+ "Fant ingen ordliste"
+ "Last inn på nytt"
+ "Sist oppdatert"
+ "Ser etter oppdateringer"
+ "Laster inn …"
+ "Hovedordliste"
+ "Avbryt"
+ "Installer"
+ "Avbryt nedlastingen"
+ "Deaktiver"
+ "Aktiver"
+ "Slett"
+ "Det valgte språket på mobileneheten din har en tilgjengelig ordliste.<br/> Vi anbefaler å <b>laste ned</b> ordlisten for %1$s. Dette forbedrer skriveopplevelsen din.<br/> <br/> Nedlastingen kan ta fra ett til to minutter via 3G. Belastninger kan påløpe hvis du ikke har et abonnement med <b>ubegrenset databruk</b>.<br/> Hvis du er usikker på hvilken abonnementstype du har, anbefaler vi deg å finne en Wi-Fi-tilkobling for å starte nedlastingen automatisk.<br/> <br/> Tips: Du kan laste ned og fjerne ordlister ved å gå til <b>Språk og inndata</b> i menyen for <b>Innstillinger</b> på mobilenheten din."
+ "Last ned nå (%1$.1f MB)"
+ "Last ned via Wi-Fi"
+ "En ordliste er tilgjengelig for %1$s"
+ "Trykk for å se gjennom og laste ned"
diff --git a/java/res/values-nl/strings.xml b/java/res/values-nl/strings.xml
index 4822617f4..55aee63e9 100644
--- a/java/res/values-nl/strings.xml
+++ b/java/res/values-nl/strings.xml
@@ -170,4 +170,36 @@
"Standaard""Taal en invoer""Invoermethode selecteren"
+
+ "Woordenboekleverancier"
+ "Woordenboekleverancier"
+ "Woordenboekservice"
+ "Informatie over woordenboekupdate"
+ "Woordenboeken toevoegen"
+ "Woordenboek beschikbaar"
+ "Instellingen voor woordenboeken"
+ "Gebruikerswoordenboeken"
+ "Gebruikerswoordenboek"
+ "Woordenboek beschikbaar"
+ "Wordt gedownload"
+ "Geïnstalleerd"
+ "Geïnstalleerd, uitgeschakeld"
+ "Verbindingsprobleem woordenboekservice"
+ "Geen woordenboeken"
+ "Vernieuwen"
+ "Laatst bijgewerkt"
+ "Controleren op updates"
+ "Laden..."
+ "Algemeen woordenboek"
+ "Annuleren"
+ "Installeren"
+ "Download annuleren"
+ "Uitschakelen"
+ "Inschakelen"
+ "Verwijderen"
+ "Er is een woordenboek beschikbaar voor de geselecteerde taal op uw mobiele apparaat.<br/> We raden u aan het woordenboek voor het %1$s te <b>downloaden</b> om uw typevaardigheid te verbeteren.<br/> <br/> De download kan een of twee minuten duren via 3G. Er kunnen kosten worden berekend als u geen <b>onbeperkt gegevensabonnement</b> heeft.<br/> Als u niet zeker weet welk gegevensabonnement u heeft, raden we u aan een wifi-verbinding te zoeken om de download automatisch te starten.<br/> <br/> Tip: u kunt woordenboeken downloaden en verwijderen door naar <b>Taal en invoer</b> in het menu <b>Instellingen</b> van uw mobiele apparaat te gaan."
+ "Nu downloaden (%1$.1f MB)"
+ "Downloaden via wifi"
+ "Er is een woordenboek beschikbaar voor %1$s"
+ "Druk om te controleren en te downloaden"
diff --git a/java/res/values-pl/strings.xml b/java/res/values-pl/strings.xml
index 7bb8b772f..68fed40ad 100644
--- a/java/res/values-pl/strings.xml
+++ b/java/res/values-pl/strings.xml
@@ -170,4 +170,39 @@
"Domyślne""Język, klawiatura, głos""Wybierz metodę wprowadzania"
+
+ "Dostawca słownika"
+ "Dostawca słownika"
+ "Usługa słownika"
+ "Informacje o aktualizacji słownika"
+ "Słowniki dodatkowe"
+ "Dostępny słownik"
+ "Ustawienia słowników"
+ "Słowniki użytkownika"
+ "Słownik użytkownika"
+ "Słownik jest dostępny"
+ "Aktualnie pobierany"
+ "Zainstalowany"
+ "Zainstalowany, wyłączony"
+ "Problem z połączeniem z usługą słownika"
+ "Brak słowników"
+ "Odśwież"
+ "Ostatnia aktualizacja"
+ "Sprawdzanie dostępności aktualizacji"
+ "Wczytywanie..."
+ "Słownik główny"
+ "Anuluj"
+ "Zainstaluj"
+ "Anuluj pobieranie"
+ "Wyłącz"
+ "Włącz"
+ "Usuń"
+
+
+
+
+
+
+ "Dla języka: %1$s jest dostępny słownik"
+ "Naciśnij, aby sprawdzić i pobrać"
diff --git a/java/res/values-pt-rPT/strings.xml b/java/res/values-pt-rPT/strings.xml
index caa4019ba..15ff65b55 100644
--- a/java/res/values-pt-rPT/strings.xml
+++ b/java/res/values-pt-rPT/strings.xml
@@ -170,4 +170,36 @@
"Predefinido""Idioma e entrada de som""Escolher o método de entrada"
+
+ "Fornecedor de Dicionário"
+ "Fornecedor de Dicionário"
+ "Serviço de Dicionário"
+ "Informações de atualização do dicionário"
+ "Dicionários suplementares"
+ "Dicionário disponível"
+ "Definições dos dicionários"
+ "Dicionários do utilizador"
+ "Dicionário do utilizador"
+ "Dicionário disponível"
+ "Transferência em curso"
+ "Instalado"
+ "Instalado, desativado"
+ "Problema ao ligar ao serviço de dicionário"
+ "Sem dicionários disponíveis"
+ "Atualizar"
+ "Última atualização"
+ "A verificar existência de atualizações"
+ "A carregar..."
+ "Dicionário principal"
+ "Cancelar"
+ "Instalar"
+ "Cancelar transferência"
+ "Desativar"
+ "Ativar"
+ "Eliminar"
+ "O idioma selecionado no seu dispositivo móvel tem um dicionário disponível.<br/> Recomendamos que <b>transfira</b> o dicionário %1$s para melhorar a sua experiência de introdução.<br/> <br/> A transferência pode demorar um ou dois minutos via 3G. Poderão ser aplicadas taxas se não tiver um <b>plano de dados ilimitado</b>.<br/> Se não tiver a certeza do plano de dados que tem, recomendamos que localize uma ligação Wi-Fi para começar a transferência automaticamente.<br/> <br/> Sugestão: pode transferir e remover dicionários acedendo a <b>Idioma e introdução</b> no menu <b>Definições</b> do seu dispositivo móvel."
+ "Transferir agora (%1$.1f MB)"
+ "Transferir via Wi-Fi"
+ "Está disponível um dicionário para %1$s"
+ "Prima para consultar e transferir"
diff --git a/java/res/values-pt/strings.xml b/java/res/values-pt/strings.xml
index ed8cdec38..313411565 100644
--- a/java/res/values-pt/strings.xml
+++ b/java/res/values-pt/strings.xml
@@ -170,4 +170,39 @@
"Padrão""Idioma e entrada""Selecione o método de entrada"
+
+ "Provedor de dicionário"
+ "Provedor de dicionário"
+ "Serviço de dicionário"
+ "Informações de atualização do dicionário"
+ "Dicionários complementares"
+ "Dicionário disponível"
+ "Configurações dos dicionários"
+ "Dicionário do usuário"
+ "Dicionário do usuário"
+ "Dicionário disponível"
+ "Download em andamento"
+ "Instalado"
+ "Instalado, desativado"
+ "Prob. de conexão c/ dic. de serv."
+ "Nenhum dicionário disponível"
+ "Atualizar"
+ "Última atualização"
+ "Verificando atualizações"
+ "Carregando..."
+ "Dicionário principal"
+ "Cancelar"
+ "Instalar"
+ "Cancelar download"
+ "Desativar"
+ "Permitir"
+ "Excluir"
+
+
+
+
+
+
+ "Há um dicionário disponível para %1$s"
+ "Pressione para consultar e fazer o download"
diff --git a/java/res/values-ro/strings.xml b/java/res/values-ro/strings.xml
index 5fbcbe5a3..d4c39af5f 100644
--- a/java/res/values-ro/strings.xml
+++ b/java/res/values-ro/strings.xml
@@ -170,4 +170,39 @@
"Prestabilit""Limbă și introducere de text""Alegeți metoda de introducere de text"
+
+ "Furnizorul dicţionarului"
+ "Furnizorul dicţionarului"
+ "Serviciul Dicţionar"
+ "Informaţii privind actualizarea dicţionarului"
+ "Dicţionare suplimentare"
+ "Dicţionar disponibil"
+ "Setări pentru dicţionare"
+ "Dicţionarele utilizatorului"
+ "Dicţionarul utilizatorului"
+ "Dicţionar disponibil"
+ "Se descarcă acum"
+ "Instalat"
+ "Instalat, dezactivat"
+ "Nu se conect. dicţ."
+ "Niciun dicţionar"
+ "Actualizaţi"
+ "Data ultimei modificări"
+ "Se verifică existenţa actualizărilor"
+ "Se încarcă..."
+ "Dicţionar principal"
+ "Anulaţi"
+ "Instalaţi"
+ "Anulaţi descărcarea"
+ "Dezactivaţi"
+ "Activaţi"
+ "Ştergeţi"
+
+
+
+
+
+
+ "Este disponibil un dicţionar pentru %1$s"
+ "Apăsaţi pentru a examina şi pentru a descărca"
diff --git a/java/res/values-ru/strings.xml b/java/res/values-ru/strings.xml
index c21bbf918..681c76dc3 100644
--- a/java/res/values-ru/strings.xml
+++ b/java/res/values-ru/strings.xml
@@ -170,4 +170,39 @@
"По умолчанию""Язык и ввод""Выберите способ ввода"
+
+ "Поставщик словарей"
+ "Поставщик словарей"
+ "Служба словарей"
+ "Обновления словаря"
+ "Дополнительные словари"
+ "Словарь доступен"
+ "Настройки словарей"
+ "Пользовательские словари"
+ "Пользовательский словарь"
+ "Словарь доступен"
+ "Загрузка..."
+ "Установлен"
+ "Установлен, отключен"
+ "Ошибка подключения"
+ "Словари недоступны"
+ "Обновить"
+ "Последнее обновление"
+ "Проверка обновлений..."
+ "Загрузка..."
+ "Основной словарь"
+ "Отмена"
+ "Установить"
+ "Отменить загрузку"
+ "Отключить"
+ "Включить"
+ "Удалить"
+
+
+
+
+
+
+ "Доступен словарь: %1$s"
+ "Нажмите, чтобы просмотреть и загрузить"
diff --git a/java/res/values-sk/strings.xml b/java/res/values-sk/strings.xml
index aad236ee2..5d0820868 100644
--- a/java/res/values-sk/strings.xml
+++ b/java/res/values-sk/strings.xml
@@ -170,4 +170,39 @@
"Predvolené""Jazyk & vstup""Zvoliť metódu vstupu"
+
+ "Poskytovateľ slovníka"
+ "Poskytovateľ slovníka"
+ "Služba slovníka"
+ "Informácie aktualizácie slovníka"
+ "Doplnkové slovníky"
+ "K dispozícii je slovník"
+ "Nastavenia pre slovníky"
+ "Používateľské slovníky"
+ "Používateľský slovník"
+ "K dispozícii je slovník"
+ "Aktuálne preberanie"
+ "Nainštalovaný"
+ "Nainštalovaný, zakázaný"
+ "Problém s pripojením k službe slovníka"
+ "Slovníky nedostupné"
+ "Obnoviť"
+ "Posledná aktualizácia"
+ "Prebieha kontrola aktualizácií"
+ "Prebieha načítavanie..."
+ "Hlavný slovník"
+ "Zrušiť"
+ "Inštalovať"
+ "Zrušiť preberanie"
+ "Zakázať"
+ "Povoliť"
+ "Odstrániť"
+
+
+
+
+
+
+ "K dispozícii je slovník pre jazyk %1$s"
+ "Stlačením skontrolujete a prevezmete"
diff --git a/java/res/values-sl/strings.xml b/java/res/values-sl/strings.xml
index f0d239fef..97e55186b 100644
--- a/java/res/values-sl/strings.xml
+++ b/java/res/values-sl/strings.xml
@@ -170,4 +170,36 @@
"Privzeto""Jezik in vnos""Izbira načina vnosa"
+
+ "Slovar"
+ "Storitev slovarja"
+ "Slovar"
+ "Podatki o posodobitvi slovarja"
+ "Dodatni slovarji"
+ "Slovar je na voljo"
+ "Nastavitve za slovarje"
+ "Uporabniški slovar"
+ "Uporabniški slovar"
+ "Slovar je na voljo"
+ "Trenutno se prenaša"
+ "Nameščeno"
+ "Nameščen, onemogočen"
+ "Težava s povezavo"
+ "Ni slovarjev"
+ "Osveži"
+ "Nazadnje posodobljeno"
+ "Preverjanje, ali so na voljo posodobitve"
+ "Nalaganje ..."
+ "Glavni slovar"
+ "Prekliči"
+ "Namesti"
+ "Prekliči prenos"
+ "Onemogoči"
+ "Omogoči"
+ "Izbriši"
+ "Za izbran jezik v mob. napravi je na voljo slovar.<br/> Za izboljšano izkušnjo tipkanja priporočamo, da <b>prenesete</b> slovar za ta jezik: %1$s.<br/> <br/> Prenos prek povezave UMTS lahko traja minuto ali dve. Če nimate <b>neomejen. prenosa podatkov</b>.<br/>, ga boste morda morali plačati. Če ne veste, kateri pod. paket imate, poiščite omrežje Wi-Fi, da prenos začnete samodejno.<br/> <br/> Nasvet: Slovarje lahko prenesete in odstranite tako, da v meniju <b>Nastavitve</b> v napravi odprete <b>Jezik in vnos</b>."
+ "Prenesi zdaj (%1$.1f MB)"
+ "Prenos prek povezave Wi-Fi"
+ "Slovar je na voljo za jezik %1$s"
+ "Pritisnite za pregled in prenos"
diff --git a/java/res/values-sr/strings.xml b/java/res/values-sr/strings.xml
index 11ff09a16..9b3022342 100644
--- a/java/res/values-sr/strings.xml
+++ b/java/res/values-sr/strings.xml
@@ -170,4 +170,39 @@
"Подразумевано""Језик и унос""Избор метода уноса"
+
+ "Добављач речника"
+ "Добављач речника"
+ "Услуга речника"
+ "Информације о ажурирању речника"
+ "Помоћни речници"
+ "Речник је доступан"
+ "Подешавања за речнике"
+ "Кориснички речници"
+ "Кориснички речник"
+ "Речник је доступан"
+ "Тренутно се преузима"
+ "Инсталирано"
+ "Инсталиран, онемогућен"
+ "Нема услуге речника"
+ "Нема доступних речника"
+ "Освежи"
+ "Последње ажурирање"
+ "Тражење ажурирања"
+ "Учитавање..."
+ "Главни речник"
+ "Откажи"
+ "Инсталирај"
+ "Откажи преузимање"
+ "Онемогући"
+ "Омогући"
+ "Избриши"
+
+
+
+
+
+
+ "Речник је доступан за %1$s"
+ "Притисните за преглед и преузимање"
diff --git a/java/res/values-sv/strings.xml b/java/res/values-sv/strings.xml
index be278fa2d..24ea428a6 100644
--- a/java/res/values-sv/strings.xml
+++ b/java/res/values-sv/strings.xml
@@ -170,4 +170,36 @@
"Standard""Språk & inmatning""Välj inmatningsmetod"
+
+ "Dictionary Provider"
+ "Dictionary Provider"
+ "Ordbokstjänst"
+ "Uppdateringsinformation för ordlista"
+ "Tilläggsordlistor"
+ "En ordlista är tillgänglig"
+ "Inställningar för ordlistor"
+ "Egna ordlistor"
+ "Egen ordlista"
+ "En ordlista är tillgänglig"
+ "Hämtas för närvarande"
+ "Installerad"
+ "Installerad, inaktiverad"
+ "Problem med att ansluta till ordlistetjänsten"
+ "Det finns inga ordböcker"
+ "Uppdatera"
+ "Senast uppdaterad"
+ "Söker efter uppdateringar"
+ "Läser in ..."
+ "Huvudordlistan"
+ "Avbryt"
+ "Installera"
+ "Avbryt hämtning"
+ "Inaktivera"
+ "Aktivera"
+ "Ta bort"
+ "Det finns en ordlista för språket du har valt i enheten.<br/> Vi rekommenderar att du <b>hämtar</b> den %1$s ordlistan.<br/> <br/> Det kan ta någon minut att hämta den över 3G. Avgifter kan tillkomma om du inte har ett abonnemang med <b>obegränsad datatrafik</b>.<br/> Om du är osäker på ditt abonnemang rekommenderar vi att du ansluter till ett Wi-Fi-nätverk och hämtar ordlistan automatiskt.<br/> <br/> Tips! Du kan hämta och ta bort ordlistor under <b>Språk och inmatning</b> i menyn <b>Inställningar</b> på enheten."
+ "Hämta nu (%1$.1f MB)"
+ "Hämta över Wi-Fi"
+ "En ordlista är tillgänglig för %1$s"
+ "Tryck om du vill granska och hämta"
diff --git a/java/res/values-sw/strings.xml b/java/res/values-sw/strings.xml
index 1fc92fb3e..a9747531f 100644
--- a/java/res/values-sw/strings.xml
+++ b/java/res/values-sw/strings.xml
@@ -170,4 +170,39 @@
"Chaguo-msingi""Lugha na uingizaji""Chagua mbinu ya kuingiza data"
+
+ "Mtoaji Kamusi"
+ "Mtoaji Kamusi"
+ "Huduma ya Kamusi"
+ "Maelezo ya kusasisha kamusi"
+ "Nyongeza za kamusi"
+ "Kamusi inapatikana"
+ "Mipangilio ya kamusi"
+ "Kamusi ya mtumiaji"
+ "Kamusi ya mtumiaji"
+ "Kamusi inapatikana"
+ "Inapakua sasa"
+ "Imesakinishwa"
+ "kusakinisha, imelemazwa"
+ "Tatizo kuunganisha kwa huduma ya kamusi"
+ "Hakuna kamusi inapatikana"
+ "Zimua"
+ "Mara ya mwisho kusasishwa"
+ "Inatafuta visasishi..."
+ "Inapakia..."
+ "Kamusi kuu"
+ "Katisha"
+ "Sakinisha"
+ "Ghairi kupakua"
+ "Lemaza"
+ "Wezesha"
+ "Futa"
+
+
+
+
+
+
+ "Kamusi inapatikana ya %1$s"
+ "Bonyeza ili kukagua na kupakua"
diff --git a/java/res/values-th/strings.xml b/java/res/values-th/strings.xml
index 84c4a0327..2cf91305d 100644
--- a/java/res/values-th/strings.xml
+++ b/java/res/values-th/strings.xml
@@ -170,4 +170,39 @@
"ค่าเริ่มต้น""ภาษาและการป้อนข้อมูล""เลือกวิธีการป้อนข้อมูล"
+
+ "ผู้ให้บริการพจนานุกรม"
+ "ผู้ให้บริการพจนานุกรม"
+ "บริการพจนานุกรม"
+ "ข้อมูลอัปเดตสำหรับพจนานุกรม"
+ "พจนานุกรม Add-On"
+ "มีพจนานุกรมให้ใช้งาน"
+ "การตั้งค่าสำหรับพจนานุกรม"
+ "พจนานุกรมผู้ใช้"
+ "พจนานุกรมผู้ใช้"
+ "มีพจนานุกรมให้ใช้งาน"
+ "กำลังดาวน์โหลดอยู่"
+ "ติดตั้งแล้ว"
+ "ติดตั้งแล้วแต่ปิดใช้งาน"
+ "พบปัญหาขณะเชื่อมต่อ"
+ "ไม่มีพจนานุกรม"
+ "รีเฟรช"
+ "ปรับปรุงล่าสุดเมื่อ"
+ "กำลังตรวจสอบการอัปเดต..."
+ "กำลังโหลด..."
+ "พจนานุกรมหลัก"
+ "ยกเลิก"
+ "ติดตั้ง"
+ "ยกเลิกการดาวน์โหลด"
+ "ปิดใช้งาน"
+ "เปิดใช้งาน"
+ "ลบ"
+
+
+
+
+
+
+ "มีพจนานุกรมให้ใช้งานในภาษา %1$s"
+ "กดเพื่อตรวจสอบและดาวน์โหลด"
diff --git a/java/res/values-tl/strings.xml b/java/res/values-tl/strings.xml
index e2a59a43b..ad5714dff 100644
--- a/java/res/values-tl/strings.xml
+++ b/java/res/values-tl/strings.xml
@@ -170,4 +170,39 @@
"Default""Wika at input""Pumili ng pamamaraan ng pag-input"
+
+ "Provider ng Diksyunaryo"
+ "Provider ng Diksyunaryo"
+ "Serbisyo ng Diksyunaryo"
+ "Impormasyon ng pag-update sa diksyunaryo"
+ "Mga diksyunaryo na add-on"
+ "Available ang diksyunaryo"
+ "Mga setting para sa mga diksyunaryo"
+ "Mga diksyunaryo ng user"
+ "Diksyunaryo ng user"
+ "Available ang diksyunaryo"
+ "Kasalukuyang nagda-download"
+ "Naka-install"
+ "Naka-install, hindi pinagana"
+ "Problema sa pagkonekta sa serbisyo ng diksyunaryo"
+ "Walang available na mga diksyunaryo"
+ "I-refresh"
+ "Huling na-update"
+ "Tumitingin ng mga update"
+ "Naglo-load..."
+ "Pangunahing diksyunaryo"
+ "Kanselahin"
+ "I-install"
+ "Kanselahin ang pag-download"
+ "Huwag paganahin"
+ "Paganahin"
+ "Tanggalin"
+
+
+
+
+
+
+ "Available ang isang diksyunaryo para sa %1$s"
+ "Pindutin upang suriin at i-download"
diff --git a/java/res/values-tr/strings.xml b/java/res/values-tr/strings.xml
index c78d56b55..7e122ad7a 100644
--- a/java/res/values-tr/strings.xml
+++ b/java/res/values-tr/strings.xml
@@ -170,4 +170,39 @@
"Varsayılan""Dil ve giriş""Giriş yöntemini seçin"
+
+ "Sözlük Sağlayıcı"
+ "Sözlük Sağlayıcı"
+ "Sözlük Hizmeti"
+ "Sözlük güncelleme bilgileri"
+ "Ekli sözlükler"
+ "Kullanılabilecek sözlük var"
+ "Sözlükler için ayarlar"
+ "Kullanıcı sözlükleri"
+ "Kullanıcı sözlüğü"
+ "Sözlük kullanılabilir"
+ "Şu anda indiriliyor"
+ "Yüklendi"
+ "Yüklendi, devre dışı"
+ "Sözlük hizmetine bağlantı yok"
+ "Kullanılabilir sözlük yok"
+ "Yenile"
+ "Son güncelleme tarihi"
+ "Güncellemeler denetleniyor..."
+ "Yükleniyor..."
+ "Ana sözlük"
+ "İptal"
+ "Yükle"
+ "İndirmeyi iptal et"
+ "Devre dışı bırak"
+ "Etkinleştir"
+ "Sil"
+
+
+
+
+
+
+ "%1$s için kullanılabilecek bir sözlük var"
+ "İncelemek ve indirmek için tıklayın"
diff --git a/java/res/values-uk/strings.xml b/java/res/values-uk/strings.xml
index a17f0d124..609c80b60 100644
--- a/java/res/values-uk/strings.xml
+++ b/java/res/values-uk/strings.xml
@@ -170,4 +170,39 @@
"За умовчанням""Мова та введення""Вибрати метод введення"
+
+ "Постачальник словника"
+ "Постачальник словника"
+ "Послуга словника"
+ "Інформація про оновлення словника"
+ "Додаткові словники"
+ "Словник доступний"
+ "Налаштування для словників"
+ "Словники користувача"
+ "Словник користувача"
+ "Словник доступний"
+ "Зараз завантажується"
+ "Установлено"
+ "Установлено, вимкнено"
+ "Нема з’єднання зі словником"
+ "Словники недоступні"
+ "Оновити"
+ "Останнє оновлення"
+ "Перевірка наявності оновлень"
+ "Завантаження..."
+ "Основний словник"
+ "Скасувати"
+ "Установити"
+ "Скасувати завантаження"
+ "Вимкнути"
+ "Увімкнути"
+ "Видалити"
+
+
+
+
+
+
+ "Доступний словник для такої мови: %1$s"
+ "Натисніть, щоб переглянути та завантажити"
diff --git a/java/res/values-vi/strings.xml b/java/res/values-vi/strings.xml
index 2dcfb1941..12d05bbde 100644
--- a/java/res/values-vi/strings.xml
+++ b/java/res/values-vi/strings.xml
@@ -170,4 +170,39 @@
"Mặc định""Ngôn ngữ và phương thức nhập""Chọn phương thức nhập"
+
+ "Nhà cung cấp từ điển"
+ "Nhà cung cấp từ điển"
+ "Dịch vụ từ điển"
+ "Thông tin cập nhật từ điển"
+ "Từ điển phụ trợ"
+ "Có sẵn từ điển"
+ "Cài đặt dành cho từ điển"
+ "Từ điển người dùng"
+ "Từ điển người dùng"
+ "Có sẵn từ điển"
+ "Hiện đang tải xuống"
+ "Đã cài đặt"
+ "Đã cài đặt, bị tắt"
+ "Lỗi knối d.vụ t.điển"
+ "Không có từ điển nào"
+ "Làm mới"
+ "Cập nhật lần cuối"
+ "Đang kiểm tra cập nhật"
+ "Đang tải..."
+ "Từ điển chính"
+ "Hủy"
+ "Cài đặt"
+ "Hủy tải xuống"
+ "Tắt"
+ "Bật"
+ "Xóa"
+
+
+
+
+
+
+ "Có sẵn từ điển cho %1$s"
+ "Nhấn để xem lại và tải xuống"
diff --git a/java/res/values-zh-rCN/strings.xml b/java/res/values-zh-rCN/strings.xml
index 3e4777639..0e2fd7c8e 100644
--- a/java/res/values-zh-rCN/strings.xml
+++ b/java/res/values-zh-rCN/strings.xml
@@ -170,4 +170,39 @@
"默认""语言和输入法""选择输入法"
+
+ "词典大全"
+ "词典大全"
+ "字典服务"
+ "词典更新信息"
+ "附加词典"
+ "词典可供下载"
+ "词典设置"
+ "用户词典"
+ "用户词典"
+ "词典可供下载"
+ "目前正在下载"
+ "已安装"
+ "已安装,已停用"
+ "连接到词典服务时发生问题"
+ "没有可用的词典"
+ "刷新"
+ "最后更新时间"
+ "正在检查更新"
+ "正在加载..."
+ "主词典"
+ "取消"
+ "安装"
+ "取消下载"
+ "停用"
+ "启用"
+ "删除"
+
+
+
+
+
+
+ "%1$s词典可供下载"
+ "按此通知即可查看和下载"
diff --git a/java/res/values-zh-rTW/strings.xml b/java/res/values-zh-rTW/strings.xml
index ab6c66402..ae4a48f81 100644
--- a/java/res/values-zh-rTW/strings.xml
+++ b/java/res/values-zh-rTW/strings.xml
@@ -170,4 +170,39 @@
"預設""語言與輸入設定""選擇輸入法"
+
+ "字典提供者"
+ "字典提供者"
+ "字典服務"
+ "字典更新資訊"
+ "外掛字典"
+ "字典可供下載"
+ "字典設定"
+ "使用者字典"
+ "使用者字典"
+ "可用的字典"
+ "目前正在下載"
+ "已安裝"
+ "已安裝但目前停用"
+ "連線至字典服務時發生問題"
+ "沒有可用的字典"
+ "重新整理"
+ "上次更新時間"
+ "正在檢查更新"
+ "載入中..."
+ "主要字典"
+ "取消"
+ "安裝"
+ "取消下載"
+ "停用"
+ "啟用"
+ "刪除"
+
+
+
+
+
+
+ "支援「%1$s」字典"
+ "按下即可查看並下載"
diff --git a/java/res/values-zu/strings.xml b/java/res/values-zu/strings.xml
index 677588b4f..9e11821a6 100644
--- a/java/res/values-zu/strings.xml
+++ b/java/res/values-zu/strings.xml
@@ -170,4 +170,39 @@
"Okuzenzakalelayo""Ulimi nokokufakwayo""Khetha indlela yokufaka"
+
+ "Umhlinzeki Wesichazamazwi"
+ "Umhlinzeki Wesichazamazwi"
+ "Insiza yesichazamazwi"
+ "Ulwazi lokubuyekeza isichazamazwi"
+ "Faka izichazamazwi"
+ "Isichazamazwi siyatholakala"
+ "Izilungiselelo zezichazamazwi"
+ "Sebenzisa isichazamazwi"
+ "Isichazamazwi Somsebenzisi"
+ "Isichazamazwi siyatholakala"
+ "Okwamanje iyalayisha"
+ "Kufakiwe"
+ "Kufakiwe, kumisiwe"
+ "Inkinga yokuxhumaniseka esevisini yesichazamazwi"
+ "Azikho izachazimazwi ezikhona"
+ "Vuselela"
+ "Igcine ukulungiswa:"
+ "Ihlola izibuyekezo..."
+ "Kuyalayisha..."
+ "Isichazimazwi sakho ngqangi"
+ "Khansela"
+ "Faka"
+ "Khansela ukulayisha"
+ "Yenza kungasebenzi"
+ "Vumela"
+ "Susa"
+
+
+
+
+
+
+ "Isichazamazwi se-%1$s siyatholakala"
+ "Cindezela ukuze ubuyekeze bese ulanda"
diff --git a/java/res/values/config.xml b/java/res/values/config.xml
index d248a68a6..a90ba8014 100644
--- a/java/res/values/config.xml
+++ b/java/res/values/config.xml
@@ -123,4 +123,13 @@
4 = ?
-->
0
+
+
+ false
+ false
+ false
+ false
+ false
+ false
+
diff --git a/java/res/values/donottranslate.xml b/java/res/values/donottranslate.xml
index edf615acc..1e70fbbba 100644
--- a/java/res/values/donottranslate.xml
+++ b/java/res/values/donottranslate.xml
@@ -210,8 +210,12 @@
qwerty
-
- com.google.android.inputmethod.latin.dictionarypack
- com.google.android.inputmethod.latin.dictionarypack.DictionarySettingsActivityAttention! You are using the special keyboard for research purposes.
+
+
+ com.android.inputmethod.dictionarypack.DictionarySettingsActivity
+ com.android.inputmethod.dictionarypack.aosp
+
+ metadata.json
+
diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml
index e89174b02..03dce9ca6 100644
--- a/java/res/values/strings.xml
+++ b/java/res/values/strings.xml
@@ -459,4 +459,78 @@
Choose input methodShow setup wizard icon
+
+
+ Dictionary Provider
+
+ Dictionary Provider
+
+ Dictionary Service
+
+
+ Dictionary update information
+
+
+ Add-on dictionaries
+
+ Dictionary available
+ Settings for dictionaries
+
+ User dictionaries
+
+ User dictionary
+
+ Dictionary available
+
+ Currently downloading
+
+ Installed
+
+ Installed, disabled
+
+
+ Problem connecting to dictionary service
+
+ No dictionaries available
+
+
+ Refresh
+
+ Last updated
+
+
+ Checking for updates
+
+ Loading...
+
+
+ Main dictionary
+
+
+ Cancel
+
+
+ Install
+
+ Cancel
+
+ Delete
+
+
+ The selected language on your mobile device has an available dictionary.<br/>
+We recommend <b>downloading</b> the %1$s dictionary to improve your typing experience.<br/>
+<br/>
+The download could take a minute or two over 3G. Charges may apply if you don\'t have an <b>unlimited data plan</b>.<br/>
+If you are not sure which data plan you have, we recommend finding a Wi-Fi connection to start the download automatically.<br/>
+<br/>
+Tip: You can download and remove dictionaries by going to <b>Language & input</b> in the <b>Settings</b> menu of your mobile device.
+ Download now (%1$.1fMB)
+ Download over Wi-Fi
+
+ A dictionary is available for %1$s
+
+ Press to review and download
+
+
+ Downloading: suggestions for %1$s will be ready soon.
diff --git a/java/res/xml/dictionary_settings.xml b/java/res/xml/dictionary_settings.xml
new file mode 100644
index 000000000..684dfe5b5
--- /dev/null
+++ b/java/res/xml/dictionary_settings.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/java/res/xml/prefs.xml b/java/res/xml/prefs.xml
index e5fef8834..51f580721 100644
--- a/java/res/xml/prefs.xml
+++ b/java/res/xml/prefs.xml
@@ -59,7 +59,6 @@
android:title="@string/configure_dictionaries_title">
+
+ It may also be possible that DisableAction or StartDeleteAction or
+ DownloadAction run when the file is still downloading. This cancels
+ the download and returns to STATUS_AVAILABLE.
+ Also, an UpdateDataAction may apply in any state. It does not affect
+ the state in any way (nor type, local filename, id or version) but
+ may update other attributes like description or remote filename.
+
+ Forget is an DB maintenance action that removes the entry if it is not installed or disabled.
+ This happens when the word list information disappeared from the server, or when a new version
+ is available and we should forget about the old one.
+*/
+public final class ActionBatch {
+ /**
+ * A piece of update.
+ *
+ * Action is basically like a Runnable that takes an argument.
+ */
+ public interface Action {
+ /**
+ * Execute this action NOW.
+ * @param context the context to get system services, resources, databases
+ */
+ public void execute(final Context context);
+ }
+
+ /**
+ * An action that starts downloading an available word list.
+ */
+ public static final class StartDownloadAction implements Action {
+ static final String TAG = "DictionaryProvider:" + StartDownloadAction.class.getSimpleName();
+
+ private final String mClientId;
+ // The data to download. May not be null.
+ final WordListMetadata mWordList;
+ final boolean mForceStartNow;
+ public StartDownloadAction(final String clientId,
+ final WordListMetadata wordList, final boolean forceStartNow) {
+ Utils.l("New download action for client ", clientId, " : ", wordList);
+ mClientId = clientId;
+ mWordList = wordList;
+ mForceStartNow = forceStartNow;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "UpdateAction with a null parameter!");
+ return;
+ }
+ Utils.l("Downloading word list");
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion);
+ final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ final DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (MetadataDbHelper.STATUS_DOWNLOADING == status) {
+ // The word list is still downloading. Cancel the download and revert the
+ // word list status to "available".
+ if (null != manager) {
+ // DownloadManager is disabled (or not installed?). We can't cancel - there
+ // is nothing we can do. We still need to mark the entry as available.
+ manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN));
+ }
+ MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
+ } else if (MetadataDbHelper.STATUS_AVAILABLE != status) {
+ // Should never happen
+ Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : " + status
+ + " for an upgrade action. Fall back to download.");
+ }
+ // Download it.
+ Utils.l("Upgrade word list, downloading", mWordList.mRemoteFilename);
+
+ // TODO: if DownloadManager is disabled or not installed, download by ourselves
+ if (null == manager) return;
+
+ // This is an upgraded word list: we should download it.
+ final Uri uri = Uri.parse(mWordList.mRemoteFilename);
+ final Request request = new Request(uri);
+
+ final Resources res = context.getResources();
+ if (!mForceStartNow) {
+ if (DownloadManagerCompatUtils.hasSetAllowedOverMetered()) {
+ final boolean allowOverMetered;
+ switch (UpdateHandler.getDownloadOverMeteredSetting(context)) {
+ case UpdateHandler.DOWNLOAD_OVER_METERED_DISALLOWED:
+ // User said no: don't allow.
+ allowOverMetered = false;
+ break;
+ case UpdateHandler.DOWNLOAD_OVER_METERED_ALLOWED:
+ // User said yes: allow.
+ allowOverMetered = true;
+ break;
+ default: // UpdateHandler.DOWNLOAD_OVER_METERED_SETTING_UNKNOWN
+ // Don't know: use the default value from configuration.
+ allowOverMetered = res.getBoolean(R.bool.allow_over_metered);
+ }
+ DownloadManagerCompatUtils.setAllowedOverMetered(request, allowOverMetered);
+ } else {
+ request.setAllowedNetworkTypes(Request.NETWORK_WIFI);
+ }
+ request.setAllowedOverRoaming(res.getBoolean(R.bool.allow_over_roaming));
+ } // if mForceStartNow, then allow all network types and roaming, which is the default.
+ request.setTitle(mWordList.mDescription);
+ request.setNotificationVisibility(
+ res.getBoolean(R.bool.display_notification_for_auto_update)
+ ? Request.VISIBILITY_VISIBLE : Request.VISIBILITY_HIDDEN);
+ request.setVisibleInDownloadsUi(
+ res.getBoolean(R.bool.dict_downloads_visible_in_download_UI));
+
+ final long downloadId = UpdateHandler.registerDownloadRequest(manager, request, db,
+ mWordList.mId, mWordList.mVersion);
+ Utils.l("Starting download of", uri, "with id", downloadId);
+ PrivateLog.log("Starting download of " + uri + ", id : " + downloadId, context);
+ }
+ }
+
+ /**
+ * An action that updates the database to reflect the status of a newly installed word list.
+ */
+ public static final class InstallAfterDownloadAction implements Action {
+ static final String TAG = "DictionaryProvider:"
+ + InstallAfterDownloadAction.class.getSimpleName();
+ private final String mClientId;
+ // The state to upgrade from. May not be null.
+ final ContentValues mWordListValues;
+
+ public InstallAfterDownloadAction(final String clientId,
+ final ContentValues wordListValues) {
+ Utils.l("New InstallAfterDownloadAction for client ", clientId, " : ", wordListValues);
+ mClientId = clientId;
+ mWordListValues = wordListValues;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordListValues) {
+ Log.e(TAG, "InstallAfterDownloadAction with a null parameter!");
+ return;
+ }
+ final int status = mWordListValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ if (MetadataDbHelper.STATUS_DOWNLOADING != status) {
+ final String id = mWordListValues.getAsString(MetadataDbHelper.WORDLISTID_COLUMN);
+ Log.e(TAG, "Unexpected state of the word list '" + id + "' : " + status
+ + " for an InstallAfterDownload action. Bailing out.");
+ return;
+ }
+ Utils.l("Setting word list as installed");
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues);
+ }
+ }
+
+ /**
+ * An action that enables an existing word list.
+ */
+ public static final class EnableAction implements Action {
+ static final String TAG = "DictionaryProvider:" + EnableAction.class.getSimpleName();
+ private final String mClientId;
+ // The state to upgrade from. May not be null.
+ final WordListMetadata mWordList;
+
+ public EnableAction(final String clientId, final WordListMetadata wordList) {
+ Utils.l("New EnableAction for client ", clientId, " : ", wordList);
+ mClientId = clientId;
+ mWordList = wordList;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) {
+ Log.e(TAG, "EnableAction with a null parameter!");
+ return;
+ }
+ Utils.l("Enabling word list");
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion);
+ final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ if (MetadataDbHelper.STATUS_DISABLED != status
+ && MetadataDbHelper.STATUS_DELETING != status) {
+ Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + " : " + status
+ + " for an enable action. Cancelling");
+ return;
+ }
+ MetadataDbHelper.markEntryAsEnabled(db, mWordList.mId, mWordList.mVersion);
+ }
+ }
+
+ /**
+ * An action that disables a word list.
+ */
+ public static final class DisableAction implements Action {
+ static final String TAG = "DictionaryProvider:" + DisableAction.class.getSimpleName();
+ private final String mClientId;
+ // The word list to disable. May not be null.
+ final WordListMetadata mWordList;
+ public DisableAction(final String clientId, final WordListMetadata wordlist) {
+ Utils.l("New Disable action for client ", clientId, " : ", wordlist);
+ mClientId = clientId;
+ mWordList = wordlist;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "DisableAction with a null word list!");
+ return;
+ }
+ Utils.l("Disabling word list : " + mWordList);
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion);
+ final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ if (MetadataDbHelper.STATUS_INSTALLED == status) {
+ // Disabling an installed word list
+ MetadataDbHelper.markEntryAsDisabled(db, mWordList.mId, mWordList.mVersion);
+ } else {
+ if (MetadataDbHelper.STATUS_DOWNLOADING != status) {
+ Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : "
+ + status + " for a disable action. Fall back to marking as available.");
+ }
+ // The word list is still downloading. Cancel the download and revert the
+ // word list status to "available".
+ final DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (null != manager) {
+ // If we can't cancel the download because DownloadManager is not available,
+ // we still need to mark the entry as available.
+ manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN));
+ }
+ MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
+ }
+ }
+ }
+
+ /**
+ * An action that makes a word list available.
+ */
+ public static final class MakeAvailableAction implements Action {
+ static final String TAG = "DictionaryProvider:" + MakeAvailableAction.class.getSimpleName();
+ private final String mClientId;
+ // The word list to make available. May not be null.
+ final WordListMetadata mWordList;
+ public MakeAvailableAction(final String clientId, final WordListMetadata wordlist) {
+ Utils.l("New MakeAvailable action", clientId, " : ", wordlist);
+ mClientId = clientId;
+ mWordList = wordlist;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "MakeAvailableAction with a null word list!");
+ return;
+ }
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ if (null != MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion)) {
+ Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' "
+ + " for a makeavailable action. Marking as available anyway.");
+ }
+ Utils.l("Making word list available : " + mWordList);
+ // If mLocalFilename is null, then it's a remote file that hasn't been downloaded
+ // yet, so we set the local filename to the empty string.
+ final ContentValues values = MetadataDbHelper.makeContentValues(0,
+ MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_AVAILABLE,
+ mWordList.mId, mWordList.mLocale, mWordList.mDescription,
+ null == mWordList.mLocalFilename ? "" : mWordList.mLocalFilename,
+ mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mChecksum,
+ mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion);
+ PrivateLog.log("Insert 'available' record for " + mWordList.mDescription
+ + " and locale " + mWordList.mLocale, context);
+ db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values);
+ }
+ }
+
+ /**
+ * An action that marks a word list as pre-installed.
+ *
+ * This is almost the same as MakeAvailableAction, as it only inserts a line with parameters
+ * received from outside.
+ * Unlike MakeAvailableAction, the parameters are not received from a downloaded metadata file
+ * but from the client directly; it marks a word list as being "installed" and not "available".
+ * It also explicitly sets the filename to the empty string, so that we don't try to open
+ * it on our side.
+ */
+ public static final class MarkPreInstalledAction implements Action {
+ static final String TAG = "DictionaryProvider:"
+ + MarkPreInstalledAction.class.getSimpleName();
+ private final String mClientId;
+ // The word list to mark pre-installed. May not be null.
+ final WordListMetadata mWordList;
+ public MarkPreInstalledAction(final String clientId, final WordListMetadata wordlist) {
+ Utils.l("New MarkPreInstalled action", clientId, " : ", wordlist);
+ mClientId = clientId;
+ mWordList = wordlist;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "MarkPreInstalledAction with a null word list!");
+ return;
+ }
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ if (null != MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion)) {
+ Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' "
+ + " for a markpreinstalled action. Marking as preinstalled anyway.");
+ }
+ Utils.l("Marking word list preinstalled : " + mWordList);
+ // This word list is pre-installed : we don't have its file. We should reset
+ // the local file name to the empty string so that we don't try to open it
+ // accidentally. The remote filename may be set by the application if it so wishes.
+ final ContentValues values = MetadataDbHelper.makeContentValues(0,
+ MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_INSTALLED,
+ mWordList.mId, mWordList.mLocale, mWordList.mDescription,
+ "", mWordList.mRemoteFilename, mWordList.mLastUpdate,
+ mWordList.mChecksum, mWordList.mFileSize, mWordList.mVersion,
+ mWordList.mFormatVersion);
+ PrivateLog.log("Insert 'preinstalled' record for " + mWordList.mDescription
+ + " and locale " + mWordList.mLocale, context);
+ db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values);
+ }
+ }
+
+ /**
+ * An action that updates information about a word list - description, locale etc
+ */
+ public static final class UpdateDataAction implements Action {
+ static final String TAG = "DictionaryProvider:" + UpdateDataAction.class.getSimpleName();
+ private final String mClientId;
+ final WordListMetadata mWordList;
+ public UpdateDataAction(final String clientId, final WordListMetadata wordlist) {
+ Utils.l("New UpdateData action for client ", clientId, " : ", wordlist);
+ mClientId = clientId;
+ mWordList = wordlist;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "UpdateDataAction with a null word list!");
+ return;
+ }
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ ContentValues oldValues = MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion);
+ if (null == oldValues) {
+ Log.e(TAG, "Trying to update data about a non-existing word list. Bailing out.");
+ return;
+ }
+ Utils.l("Updating data about a word list : " + mWordList);
+ final ContentValues values = MetadataDbHelper.makeContentValues(
+ oldValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN),
+ oldValues.getAsInteger(MetadataDbHelper.TYPE_COLUMN),
+ oldValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN),
+ mWordList.mId, mWordList.mLocale, mWordList.mDescription,
+ oldValues.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN),
+ mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mChecksum,
+ mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion);
+ PrivateLog.log("Updating record for " + mWordList.mDescription
+ + " and locale " + mWordList.mLocale, context);
+ db.update(MetadataDbHelper.METADATA_TABLE_NAME, values,
+ MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
+ + MetadataDbHelper.VERSION_COLUMN + " = ?",
+ new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
+ }
+ }
+
+ /**
+ * An action that deletes the metadata about a word list if possible.
+ *
+ * This is triggered when a specific word list disappeared from the server, or when a fresher
+ * word list is available and the old one was not installed.
+ * If the word list has not been installed, it's possible to delete its associated metadata.
+ * Otherwise, the settings are retained so that the user can still administrate it.
+ */
+ public static final class ForgetAction implements Action {
+ static final String TAG = "DictionaryProvider:" + ForgetAction.class.getSimpleName();
+ private final String mClientId;
+ // The word list to remove. May not be null.
+ final WordListMetadata mWordList;
+ final boolean mHasNewerVersion;
+ public ForgetAction(final String clientId, final WordListMetadata wordlist,
+ final boolean hasNewerVersion) {
+ Utils.l("New TryRemove action for client ", clientId, " : ", wordlist);
+ mClientId = clientId;
+ mWordList = wordlist;
+ mHasNewerVersion = hasNewerVersion;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "TryRemoveAction with a null word list!");
+ return;
+ }
+ Utils.l("Trying to remove word list : " + mWordList);
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion);
+ if (null == values) {
+ Log.e(TAG, "Trying to update the metadata of a non-existing wordlist. Cancelling.");
+ return;
+ }
+ final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ if (mHasNewerVersion && MetadataDbHelper.STATUS_AVAILABLE != status) {
+ // If we have a newer version of this word list, we should be here ONLY if it was
+ // not installed - else we should be upgrading it.
+ Log.e(TAG, "Unexpected status for forgetting a word list info : " + status
+ + ", removing URL to prevent re-download");
+ }
+ if (MetadataDbHelper.STATUS_INSTALLED == status
+ || MetadataDbHelper.STATUS_DISABLED == status
+ || MetadataDbHelper.STATUS_DELETING == status) {
+ // If it is installed or disabled, then we cannot remove the entry lest the user
+ // lose the ability to delete the file or otherwise administrate it. We will thus
+ // leave it as is, but remove the URI from the database since it is not supposed to
+ // be accessible any more.
+ // If it is deleting and we don't have a new version, then we have to wait until
+ // Android Keyboard actually has deleted it before we can remove its metadata.
+ values.put(MetadataDbHelper.REMOTE_FILENAME_COLUMN, "");
+ db.update(MetadataDbHelper.METADATA_TABLE_NAME, values,
+ MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
+ + MetadataDbHelper.VERSION_COLUMN + " = ?",
+ new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
+ } else {
+ // If it's AVAILABLE or DOWNLOADING or even UNKNOWN, delete the entry.
+ db.delete(MetadataDbHelper.METADATA_TABLE_NAME,
+ MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
+ + MetadataDbHelper.VERSION_COLUMN + " = ?",
+ new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
+ }
+ }
+ }
+
+ /**
+ * An action that sets the word list for deletion as soon as possible.
+ *
+ * This is triggered when the user requests deletion of a word list. This will mark it as
+ * deleted in the database, and fire an intent for Android Keyboard to take notice and
+ * reload its dictionaries right away if it is up. If it is not up now, then it will
+ * delete the actual file the next time it gets up.
+ * A file marked as deleted causes the content provider to supply a zero-sized file to
+ * Android Keyboard, which will overwrite any existing file and provide no words for this
+ * word list. This is not exactly a "deletion", since there is an actual file which takes up
+ * a few bytes on the disk, but this allows to override a default dictionary with an empty
+ * dictionary. This way, there is no need for the user to make a distinction between
+ * dictionaries installed by default and add-on dictionaries.
+ */
+ public static final class StartDeleteAction implements Action {
+ static final String TAG = "DictionaryProvider:" + StartDeleteAction.class.getSimpleName();
+ private final String mClientId;
+ // The word list to delete. May not be null.
+ final WordListMetadata mWordList;
+ public StartDeleteAction(final String clientId, final WordListMetadata wordlist) {
+ Utils.l("New StartDelete action for client ", clientId, " : ", wordlist);
+ mClientId = clientId;
+ mWordList = wordlist;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "StartDeleteAction with a null word list!");
+ return;
+ }
+ Utils.l("Trying to delete word list : " + mWordList);
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion);
+ if (null == values) {
+ Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling.");
+ return;
+ }
+ final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ if (MetadataDbHelper.STATUS_DISABLED != status) {
+ Log.e(TAG, "Unexpected status for deleting a word list info : " + status);
+ }
+ MetadataDbHelper.markEntryAsDeleting(db, mWordList.mId, mWordList.mVersion);
+ }
+ }
+
+ /**
+ * An action that validates a word list as deleted.
+ *
+ * This will restore the word list as available if it still is, or remove the entry if
+ * it is not any more.
+ */
+ public static final class FinishDeleteAction implements Action {
+ static final String TAG = "DictionaryProvider:" + FinishDeleteAction.class.getSimpleName();
+ private final String mClientId;
+ // The word list to delete. May not be null.
+ final WordListMetadata mWordList;
+ public FinishDeleteAction(final String clientId, final WordListMetadata wordlist) {
+ Utils.l("New FinishDelete action for client", clientId, " : ", wordlist);
+ mClientId = clientId;
+ mWordList = wordlist;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "FinishDeleteAction with a null word list!");
+ return;
+ }
+ Utils.l("Trying to delete word list : " + mWordList);
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
+ mWordList.mId, mWordList.mVersion);
+ if (null == values) {
+ Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling.");
+ return;
+ }
+ final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ if (MetadataDbHelper.STATUS_DELETING != status) {
+ Log.e(TAG, "Unexpected status for finish-deleting a word list info : " + status);
+ }
+ final String remoteFilename =
+ values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN);
+ // If there isn't a remote filename any more, then we don't know where to get the file
+ // from any more, so we remove the entry entirely. As a matter of fact, if the file was
+ // marked DELETING but disappeared from the metadata on the server, it ended up
+ // this way.
+ if (TextUtils.isEmpty(remoteFilename)) {
+ db.delete(MetadataDbHelper.METADATA_TABLE_NAME,
+ MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
+ + MetadataDbHelper.VERSION_COLUMN + " = ?",
+ new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
+ } else {
+ MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
+ }
+ }
+ }
+
+ // An action batch consists of an ordered queue of Actions that can execute.
+ private final Queue mActions;
+
+ public ActionBatch() {
+ mActions = new LinkedList();
+ }
+
+ public void add(final Action a) {
+ mActions.add(a);
+ }
+
+ /**
+ * Append all the actions of another action batch.
+ * @param that the upgrade to merge into this one.
+ */
+ public void append(final ActionBatch that) {
+ for (final Action a : that.mActions) {
+ add(a);
+ }
+ }
+
+ /**
+ * Execute this batch.
+ *
+ * @param context the context for getting resources, databases, system services.
+ * @param reporter a Reporter to send errors to.
+ */
+ public void execute(final Context context, final ProblemReporter reporter) {
+ Utils.l("Executing a batch of actions");
+ Queue remainingActions = mActions;
+ while (!remainingActions.isEmpty()) {
+ final Action a = remainingActions.poll();
+ try {
+ a.execute(context);
+ } catch (Exception e) {
+ if (null != reporter)
+ reporter.report(e);
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/AssetFileAddress.java b/java/src/com/android/inputmethod/dictionarypack/AssetFileAddress.java
new file mode 100644
index 000000000..bebb59fc0
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/AssetFileAddress.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import java.io.File;
+
+/**
+ * Immutable class to hold the address of an asset.
+ * As opposed to a normal file, an asset is usually represented as a contiguous byte array in
+ * the package file. Open it correctly thus requires the name of the package it is in, but
+ * also the offset in the file and the length of this data. This class encapsulates these three.
+ */
+final class AssetFileAddress {
+ public final String mFilename;
+ public final long mOffset;
+ public final long mLength;
+
+ public AssetFileAddress(final String filename, final long offset, final long length) {
+ mFilename = filename;
+ mOffset = offset;
+ mLength = length;
+ }
+
+ /**
+ * Makes an AssetFileAddress. This may return null.
+ *
+ * @param filename the filename.
+ * @return the address, or null if the file does not exist or the parameters are not valid.
+ */
+ public static AssetFileAddress makeFromFileName(final String filename) {
+ if (null == filename) return null;
+ final File f = new File(filename);
+ if (!f.isFile()) return null;
+ return new AssetFileAddress(filename, 0l, f.length());
+ }
+
+ /**
+ * Makes an AssetFileAddress. This may return null.
+ *
+ * @param filename the filename.
+ * @param offset the offset.
+ * @param length the length.
+ * @return the address, or null if the file does not exist or the parameters are not valid.
+ */
+ public static AssetFileAddress makeFromFileNameAndOffset(final String filename,
+ final long offset, final long length) {
+ if (null == filename) return null;
+ final File f = new File(filename);
+ if (!f.isFile()) return null;
+ return new AssetFileAddress(filename, offset, length);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/BadFormatException.java b/java/src/com/android/inputmethod/dictionarypack/BadFormatException.java
new file mode 100644
index 000000000..d3090ddb0
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/BadFormatException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+/**
+ * Exception thrown when the metadata for the dictionary does not comply to a known format.
+ */
+public final class BadFormatException extends Exception {
+ public BadFormatException() {
+ super();
+ }
+
+ public BadFormatException(final String message) {
+ super(message);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/CommonPreferences.java b/java/src/com/android/inputmethod/dictionarypack/CommonPreferences.java
new file mode 100644
index 000000000..7c27e6d51
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/CommonPreferences.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+public final class CommonPreferences {
+ private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs";
+
+ public static SharedPreferences getCommonPreferences(final Context context) {
+ return context.getSharedPreferences(COMMON_PREFERENCES_NAME, Context.MODE_WORLD_READABLE);
+ }
+
+ public static void enable(final SharedPreferences pref, final String id) {
+ final SharedPreferences.Editor editor = pref.edit();
+ editor.putBoolean(id, true);
+ editor.apply();
+ }
+
+ public static void disable(final SharedPreferences pref, final String id) {
+ final SharedPreferences.Editor editor = pref.edit();
+ editor.putBoolean(id, false);
+ editor.apply();
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/CompletedDownloadInfo.java b/java/src/com/android/inputmethod/dictionarypack/CompletedDownloadInfo.java
new file mode 100644
index 000000000..ff198756e
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/CompletedDownloadInfo.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.DownloadManager;
+
+/**
+ * Struct class to encapsulate the result of a completed download.
+ */
+public class CompletedDownloadInfo {
+ final String mUri;
+ final long mDownloadId;
+ final int mStatus;
+ public CompletedDownloadInfo(final String uri, final long downloadId, final int status) {
+ mUri = uri;
+ mDownloadId = downloadId;
+ mStatus = status;
+ }
+ public boolean wasSuccessful() {
+ return DownloadManager.STATUS_SUCCESSFUL == mStatus;
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DictionaryProvider.java b/java/src/com/android/inputmethod/dictionarypack/DictionaryProvider.java
new file mode 100644
index 000000000..2da871305
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DictionaryProvider.java
@@ -0,0 +1,533 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.content.res.AssetFileDescriptor;
+import android.database.AbstractCursor;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.inputmethod.latin.R;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+
+/**
+ * Provider for dictionaries.
+ *
+ * This class is a ContentProvider exposing all available dictionary data as managed by
+ * the dictionary pack.
+ */
+public final class DictionaryProvider extends ContentProvider {
+ private static final String TAG = DictionaryProvider.class.getSimpleName();
+ public static final boolean DEBUG = false;
+
+ // Authority and URI matching for the ContentProvider protocol.
+ // TODO: find some way to factorize this string with the one in the resources
+ public static final String AUTHORITY = "com.android.inputmethod.dictionarypack.aosp";
+ public static final Uri CONTENT_URI =
+ Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + AUTHORITY);
+ private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt";
+ private static final String QUERY_PARAMETER_TRUE = "true";
+ private static final String QUERY_PARAMETER_DELETE_RESULT = "result";
+ private static final String QUERY_PARAMETER_SUCCESS = "success";
+ private static final String QUERY_PARAMETER_FAILURE = "failure";
+ public static final String QUERY_PARAMETER_PROTOCOL_VERSION = "protocol";
+ private static final int NO_MATCH = 0;
+ private static final int DICTIONARY_V1_WHOLE_LIST = 1;
+ private static final int DICTIONARY_V1_DICT_INFO = 2;
+ private static final int DICTIONARY_V2_METADATA = 3;
+ private static final int DICTIONARY_V2_WHOLE_LIST = 4;
+ private static final int DICTIONARY_V2_DICT_INFO = 5;
+ private static final int DICTIONARY_V2_DATAFILE = 6;
+ private static final UriMatcher sUriMatcherV1 = new UriMatcher(NO_MATCH);
+ private static final UriMatcher sUriMatcherV2 = new UriMatcher(NO_MATCH);
+ static
+ {
+ sUriMatcherV1.addURI(AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST);
+ sUriMatcherV1.addURI(AUTHORITY, "*", DICTIONARY_V1_DICT_INFO);
+ sUriMatcherV2.addURI(AUTHORITY, "*/metadata", DICTIONARY_V2_METADATA);
+ sUriMatcherV2.addURI(AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST);
+ sUriMatcherV2.addURI(AUTHORITY, "*/dict/*", DICTIONARY_V2_DICT_INFO);
+ sUriMatcherV2.addURI(AUTHORITY, "*/datafile/*", DICTIONARY_V2_DATAFILE);
+ }
+
+ // MIME types for dictionary and dictionary list, as required by ContentProvider contract.
+ public static final String DICT_LIST_MIME_TYPE =
+ "vnd.android.cursor.item/vnd.google.dictionarylist";
+ public static final String DICT_DATAFILE_MIME_TYPE =
+ "vnd.android.cursor.item/vnd.google.dictionary";
+
+ public static final String ID_CATEGORY_SEPARATOR = ":";
+
+ private static final class WordListInfo {
+ public final String mId;
+ public final String mLocale;
+ public final int mMatchLevel;
+ public WordListInfo(final String id, final String locale, final int matchLevel) {
+ mId = id;
+ mLocale = locale;
+ mMatchLevel = matchLevel;
+ }
+ }
+
+ /**
+ * A cursor for returning a list of file ids from a List of strings.
+ *
+ * This simulates only the necessary methods. It has no error handling to speak of,
+ * and does not support everything a database does, only a few select necessary methods.
+ */
+ private static final class ResourcePathCursor extends AbstractCursor {
+
+ // Column names for the cursor returned by this content provider.
+ static private final String[] columnNames = { "id", "locale" };
+
+ // The list of word lists served by this provider that match the client request.
+ final WordListInfo[] mWordLists;
+ // Note : the cursor also uses mPos, which is defined in AbstractCursor.
+
+ public ResourcePathCursor(final Collection wordLists) {
+ // Allocating a 0-size WordListInfo here allows the toArray() method
+ // to ensure we have a strongly-typed array. It's thrown out. That's
+ // what the documentation of #toArray says to do in order to get a
+ // new strongly typed array of the correct size.
+ mWordLists = wordLists.toArray(new WordListInfo[0]);
+ mPos = 0;
+ }
+
+ @Override
+ public String[] getColumnNames() {
+ return columnNames;
+ }
+
+ @Override
+ public int getCount() {
+ return mWordLists.length;
+ }
+
+ @Override public double getDouble(int column) { return 0; }
+ @Override public float getFloat(int column) { return 0; }
+ @Override public int getInt(int column) { return 0; }
+ @Override public short getShort(int column) { return 0; }
+ @Override public long getLong(int column) { return 0; }
+
+ @Override public String getString(final int column) {
+ switch (column) {
+ case 0: return mWordLists[mPos].mId;
+ case 1: return mWordLists[mPos].mLocale;
+ default : return null;
+ }
+ }
+
+ @Override
+ public boolean isNull(final int column) {
+ if (mPos >= mWordLists.length) return true;
+ return column != 0;
+ }
+ }
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ private static int matchUri(final Uri uri) {
+ int protocolVersion = 1;
+ final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION);
+ if ("2".equals(protocolVersionArg)) protocolVersion = 2;
+ switch (protocolVersion) {
+ case 1: return sUriMatcherV1.match(uri);
+ case 2: return sUriMatcherV2.match(uri);
+ default: return NO_MATCH;
+ }
+ }
+
+ private static String getClientId(final Uri uri) {
+ int protocolVersion = 1;
+ final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION);
+ if ("2".equals(protocolVersionArg)) protocolVersion = 2;
+ switch (protocolVersion) {
+ case 1: return null; // In protocol 1, the client ID is always null.
+ case 2: return uri.getPathSegments().get(0);
+ default: return null;
+ }
+ }
+
+ /**
+ * Returns the MIME type of the content associated with an Uri
+ *
+ * @see android.content.ContentProvider#getType(android.net.Uri)
+ *
+ * @param uri the URI of the content the type of which should be returned.
+ * @return the MIME type, or null if the URL is not recognized.
+ */
+ @Override
+ public String getType(final Uri uri) {
+ PrivateLog.log("Asked for type of : " + uri, this);
+ final int match = matchUri(uri);
+ switch (match) {
+ case NO_MATCH: return null;
+ case DICTIONARY_V1_WHOLE_LIST:
+ case DICTIONARY_V1_DICT_INFO:
+ case DICTIONARY_V2_WHOLE_LIST:
+ case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE;
+ case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE;
+ default: return null;
+ }
+ }
+
+ /**
+ * Query the provider for dictionary files.
+ *
+ * This version dispatches the query according to the protocol version found in the
+ * ?protocol= query parameter. If absent or not well-formed, it defaults to 1.
+ * @see android.content.ContentProvider#query(Uri, String[], String, String[], String)
+ *
+ * @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format)
+ * @param projection ignored. All columns are always returned.
+ * @param selection ignored.
+ * @param selectionArgs ignored.
+ * @param sortOrder ignored. The results are always returned in no particular order.
+ * @return a cursor matching the uri, or null if the URI was not recognized.
+ */
+ @Override
+ public Cursor query(final Uri uri, final String[] projection, final String selection,
+ final String[] selectionArgs, final String sortOrder) {
+ Utils.l("Uri =", uri);
+ PrivateLog.log("Query : " + uri, this);
+ final String clientId = getClientId(uri);
+ final int match = matchUri(uri);
+ switch (match) {
+ case DICTIONARY_V1_WHOLE_LIST:
+ case DICTIONARY_V2_WHOLE_LIST:
+ final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId);
+ Utils.l("List of dictionaries with count", c.getCount());
+ PrivateLog.log("Returned a list of " + c.getCount() + " items", this);
+ return c;
+ case DICTIONARY_V2_DICT_INFO:
+ // In protocol version 2, we return null if the client is unknown. Otherwise
+ // we behave exactly like for protocol 1.
+ if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null;
+ // Fall through
+ case DICTIONARY_V1_DICT_INFO:
+ final String locale = uri.getLastPathSegment();
+ // If LatinIME does not have a dictionary for this locale at all, it will
+ // send us true for this value. In this case, we may prompt the user for
+ // a decision about downloading a dictionary even over a metered connection.
+ final String mayPromptValue =
+ uri.getQueryParameter(QUERY_PARAMETER_MAY_PROMPT_USER);
+ final boolean mayPrompt = QUERY_PARAMETER_TRUE.equals(mayPromptValue);
+ final Collection dictFiles =
+ getDictionaryWordListsForLocale(clientId, locale, mayPrompt);
+ // TODO: pass clientId to the following function
+ DictionaryService.updateNowIfNotUpdatedInAVeryLongTime(getContext());
+ if (null != dictFiles && dictFiles.size() > 0) {
+ PrivateLog.log("Returned " + dictFiles.size() + " files", this);
+ return new ResourcePathCursor(dictFiles);
+ } else {
+ PrivateLog.log("No dictionary files for this URL", this);
+ return new ResourcePathCursor(Collections.emptyList());
+ }
+ // V2_METADATA and V2_DATAFILE are not supported for query()
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Helper method to get the wordlist metadata associated with a wordlist ID.
+ *
+ * @param clientId the ID of the client
+ * @param wordlistId the ID of the wordlist for which to get the metadata.
+ * @return the metadata for this wordlist ID, or null if none could be found.
+ */
+ private ContentValues getWordlistMetadataForWordlistId(final String clientId,
+ final String wordlistId) {
+ final Context context = getContext();
+ if (TextUtils.isEmpty(wordlistId)) return null;
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
+ return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId(
+ db, wordlistId);
+ }
+
+ /**
+ * Opens an asset file for an URI.
+ *
+ * Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or
+ * {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a
+ * dictionary.
+ * @see android.content.ContentProvider#openAssetFile(Uri, String)
+ *
+ * @param uri the URI the file is for.
+ * @param mode the mode to read the file. MUST be "r" for readonly.
+ * @return the descriptor, or null if the file is not found or if mode is not equals to "r".
+ */
+ @Override
+ public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) {
+ if (null == mode || !"r".equals(mode)) return null;
+
+ final int match = matchUri(uri);
+ if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) {
+ // Unsupported URI for openAssetFile
+ Log.w(TAG, "Unsupported URI for openAssetFile : " + uri);
+ return null;
+ }
+ final String wordlistId = uri.getLastPathSegment();
+ final String clientId = getClientId(uri);
+ final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId);
+
+ if (null == wordList) return null;
+
+ try {
+ final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ if (MetadataDbHelper.STATUS_DELETING == status) {
+ // This will return an empty file (R.raw.empty points at an empty dictionary)
+ // This is how we "delete" the files. It allows Android Keyboard to fake deleting
+ // a default dictionary - which is actually in its assets and can't be really
+ // deleted.
+ final AssetFileDescriptor afd = getContext().getResources().openRawResourceFd(
+ R.raw.empty);
+ return afd;
+ } else {
+ final String localFilename =
+ wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
+ final File f = getContext().getFileStreamPath(localFilename);
+ final ParcelFileDescriptor pfd =
+ ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY);
+ return new AssetFileDescriptor(pfd, 0, pfd.getStatSize());
+ }
+ } catch (FileNotFoundException e) {
+ // No file : fall through and return null
+ }
+ return null;
+ }
+
+ /**
+ * Reads the metadata and returns the collection of dictionaries for a given locale.
+ *
+ * Word list IDs are expected to be in the form category:manual_id. This method
+ * will select only one word list for each category: the one with the most specific
+ * locale matching the locale specified in the URI. The manual id serves only to
+ * distinguish a word list from another for the purpose of updating, and is arbitrary
+ * but may not contain a colon.
+ *
+ * @param clientId the ID of the client requesting the list
+ * @param locale the locale for which we want the list, as a String
+ * @param mayPrompt true if we are allowed to prompt the user for arbitration via notification
+ * @return a collection of ids. It is guaranteed to be non-null, but may be empty.
+ */
+ private Collection getDictionaryWordListsForLocale(final String clientId,
+ final String locale, final boolean mayPrompt) {
+ final Context context = getContext();
+ final Cursor results =
+ MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context,
+ clientId);
+ if (null == results) {
+ return Collections.emptyList();
+ } else {
+ final HashMap dicts = new HashMap();
+ final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN);
+ final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN);
+ final int localFileNameIndex =
+ results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
+ final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN);
+ if (results.moveToFirst()) {
+ do {
+ final String wordListId = results.getString(idIndex);
+ if (TextUtils.isEmpty(wordListId)) continue;
+ final String[] wordListIdArray =
+ TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR);
+ final String wordListCategory;
+ if (2 == wordListIdArray.length) {
+ // This is at the category:manual_id format.
+ wordListCategory = wordListIdArray[0];
+ // We don't need to read wordListIdArray[1] here, because it's irrelevant to
+ // word list selection - it's just a name we use to identify which data file
+ // is a newer version of which word list. We do however return the full id
+ // string for each selected word list, so in this sense we are 'using' it.
+ } else {
+ // This does not contain a colon, like the old format does. Old-format IDs
+ // always point to main dictionaries, so we force the main category upon it.
+ wordListCategory = UpdateHandler.MAIN_DICTIONARY_CATEGORY;
+ }
+ final String wordListLocale = results.getString(localeIndex);
+ final String wordListLocalFilename = results.getString(localFileNameIndex);
+ final int wordListStatus = results.getInt(statusIndex);
+ // Test the requested locale against this wordlist locale. The requested locale
+ // has to either match exactly or be more specific than the dictionary - a
+ // dictionary for "en" would match both a request for "en" or for "en_US", but a
+ // dictionary for "en_GB" would not match a request for "en_US". Thus if all
+ // three of "en" "en_US" and "en_GB" dictionaries are installed, a request for
+ // "en_US" would match "en" and "en_US", and a request for "en" only would only
+ // match the generic "en" dictionary. For more details, see the documentation
+ // for LocaleUtils#getMatchLevel.
+ final int matchLevel = LocaleUtils.getMatchLevel(wordListLocale, locale);
+ if (!LocaleUtils.isMatch(matchLevel)) {
+ // The locale of this wordlist does not match the required locale.
+ // Skip this wordlist and go to the next.
+ continue;
+ }
+ if (MetadataDbHelper.STATUS_INSTALLED == wordListStatus) {
+ // If the file does not exist, it has been deleted and the IME should
+ // already have it. Do not return it. However, this only applies if the
+ // word list is INSTALLED, for if it is DELETING we should return it always
+ // so that Android Keyboard can perform the actual deletion.
+ final File f = getContext().getFileStreamPath(wordListLocalFilename);
+ if (!f.isFile()) {
+ continue;
+ }
+ } else if (MetadataDbHelper.STATUS_AVAILABLE == wordListStatus) {
+ // The locale is the id for the main dictionary.
+ UpdateHandler.installIfNeverRequested(context, clientId, wordListId,
+ mayPrompt);
+ continue;
+ }
+ final WordListInfo currentBestMatch = dicts.get(wordListCategory);
+ if (null == currentBestMatch
+ || currentBestMatch.mMatchLevel < matchLevel) {
+ dicts.put(wordListCategory,
+ new WordListInfo(wordListId, wordListLocale, matchLevel));
+ }
+ } while (results.moveToNext());
+ }
+ results.close();
+ return Collections.unmodifiableCollection(dicts.values());
+ }
+ }
+
+ /**
+ * Deletes the file pointed by Uri, as returned by openAssetFile.
+ *
+ * @param uri the URI the file is for.
+ * @param selection ignored
+ * @param selectionArgs ignored
+ * @return the number of files deleted (0 or 1 in the current implementation)
+ * @see android.content.ContentProvider#delete(Uri, String, String[])
+ */
+ @Override
+ public int delete(final Uri uri, final String selection, final String[] selectionArgs)
+ throws UnsupportedOperationException {
+ final int match = matchUri(uri);
+ if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) {
+ return deleteDataFile(uri);
+ }
+ if (DICTIONARY_V2_METADATA == match) {
+ if (MetadataDbHelper.deleteClient(getContext(), getClientId(uri))) {
+ return 1;
+ }
+ return 0;
+ }
+ // Unsupported URI for delete
+ return 0;
+ }
+
+ private int deleteDataFile(final Uri uri) {
+ final String wordlistId = uri.getLastPathSegment();
+ final String clientId = getClientId(uri);
+ final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId);
+ if (null == wordList) return 0;
+ final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN);
+ if (MetadataDbHelper.STATUS_DELETING == status) {
+ UpdateHandler.markAsDeleted(getContext(), clientId, wordlistId, version, status);
+ return 1;
+ } else if (MetadataDbHelper.STATUS_INSTALLED == status) {
+ final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT);
+ if (QUERY_PARAMETER_FAILURE.equals(result)) {
+ UpdateHandler.markAsBroken(getContext(), clientId, wordlistId, version);
+ }
+ final String localFilename =
+ wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
+ final File f = getContext().getFileStreamPath(localFilename);
+ // f.delete() returns true if the file was successfully deleted, false otherwise
+ if (f.delete()) {
+ return 1;
+ } else {
+ return 0;
+ }
+ } else {
+ Log.e(TAG, "Attempt to delete a file whose status is " + status);
+ return 0;
+ }
+ }
+
+ /**
+ * Insert data into the provider. May be either a metadata source URL or some dictionary info.
+ *
+ * @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs.
+ * @param values the values to insert for this content uri
+ * @return the URI for the newly inserted item. May be null if arguments don't allow for insert
+ */
+ @Override
+ public Uri insert(final Uri uri, final ContentValues values)
+ throws UnsupportedOperationException {
+ if (null == uri || null == values) return null; // Should never happen but let's be safe
+ PrivateLog.log("Insert, uri = " + uri.toString(), this);
+ final String clientId = getClientId(uri);
+ switch (matchUri(uri)) {
+ case DICTIONARY_V2_METADATA:
+ // The values should contain a valid client ID and a valid URI for the metadata.
+ // The client ID may not be null, nor may it be empty because the empty client ID
+ // is reserved for internal use.
+ // The metadata URI may not be null, but it may be empty if the client does not
+ // want the dictionary pack to update the metadata automatically.
+ MetadataDbHelper.updateClientInfo(getContext(), clientId, values);
+ break;
+ case DICTIONARY_V2_DICT_INFO:
+ try {
+ final WordListMetadata newDictionaryMetadata =
+ WordListMetadata.createFromContentValues(
+ MetadataDbHelper.completeWithDefaultValues(values));
+ new ActionBatch.MarkPreInstalledAction(clientId, newDictionaryMetadata)
+ .execute(getContext());
+ } catch (final BadFormatException e) {
+ Log.w(TAG, "Not enough information to insert this dictionary " + values, e);
+ }
+ break;
+ case DICTIONARY_V1_WHOLE_LIST:
+ case DICTIONARY_V1_DICT_INFO:
+ PrivateLog.log("Attempt to insert : " + uri, this);
+ throw new UnsupportedOperationException(
+ "Insertion in the dictionary is not supported in this version");
+ }
+ return uri;
+ }
+
+ /**
+ * Updating data is not supported, and will throw an exception.
+ * @see android.content.ContentProvider#update(Uri, ContentValues, String, String[])
+ * @see android.content.ContentProvider#insert(Uri, ContentValues)
+ */
+ @Override
+ public int update(final Uri uri, final ContentValues values, final String selection,
+ final String[] selectionArgs) throws UnsupportedOperationException {
+ PrivateLog.log("Attempt to update : " + uri, this);
+ throw new UnsupportedOperationException("Updating dictionary words is not supported");
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DictionaryService.java b/java/src/com/android/inputmethod/dictionarypack/DictionaryService.java
new file mode 100644
index 000000000..5817eb498
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DictionaryService.java
@@ -0,0 +1,242 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.IBinder;
+import android.text.format.DateUtils;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.android.inputmethod.latin.R;
+
+import java.util.Locale;
+import java.util.Random;
+
+/**
+ * Service that handles background tasks for the dictionary provider.
+ *
+ * This service provides the context for the long-running operations done by the
+ * dictionary provider. Those include:
+ * - Checking for the last update date and scheduling the next update. This runs every
+ * day around midnight, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast.
+ * Every four days, it schedules an update of the metadata with the alarm manager.
+ * - Issuing the order to update the metadata. This runs every four days, between 0 and
+ * 6, upon reception of the UPDATE_NOW_INTENT_ACTION broadcast sent by the alarm manager
+ * as a result of the above action.
+ * - Handling a download that just ended. These come in two flavors:
+ * - Metadata is finished downloading. We should check whether there are new dictionaries
+ * available, and download those that we need that have new versions.
+ * - A dictionary file finished downloading. We should put the file ready for a client IME
+ * to access, and mark the current state as such.
+ */
+public final class DictionaryService extends Service {
+ private static final String TAG = DictionaryService.class.getName();
+
+ /**
+ * The package name, to use in the intent actions.
+ */
+ private static final String PACKAGE_NAME = "com.android.android.inputmethod.latin";
+
+ /**
+ * The action of the intent to tell the dictionary provider to update now.
+ */
+ private static final String UPDATE_NOW_INTENT_ACTION = PACKAGE_NAME + ".UPDATE_NOW";
+
+ /**
+ * The action of the date changing, used to schedule a periodic freshness check
+ */
+ private static final String DATE_CHANGED_INTENT_ACTION =
+ Intent.ACTION_DATE_CHANGED;
+
+ /**
+ * The action of displaying a toast to warn the user an automatic download is starting.
+ */
+ /* package */ static final String SHOW_DOWNLOAD_TOAST_INTENT_ACTION =
+ PACKAGE_NAME + ".SHOW_DOWNLOAD_TOAST_INTENT_ACTION";
+
+ /**
+ * A locale argument, as a String.
+ */
+ /* package */ static final String LOCALE_INTENT_ARGUMENT = "locale";
+
+ /**
+ * How often, in milliseconds, we want to update the metadata. This is a
+ * floor value; actually, it may happen several hours later, or even more.
+ */
+ private static final long UPDATE_FREQUENCY = 4 * DateUtils.DAY_IN_MILLIS;
+
+ /**
+ * We are waked around midnight, local time. We want to wake between midnight and 6 am,
+ * roughly. So use a random time between 0 and this delay.
+ */
+ private static final int MAX_ALARM_DELAY = 6 * ((int)AlarmManager.INTERVAL_HOUR);
+
+ /**
+ * How long we consider a "very long time". If no update took place in this time,
+ * the content provider will trigger an update in the background.
+ */
+ private static final long VERY_LONG_TIME = 14 * DateUtils.DAY_IN_MILLIS;
+
+ /**
+ * The last seen start Id. This must be stored because we must only call stopSelfResult() with
+ * the last seen Id, or the service won't stop.
+ */
+ private int mLastSeenStartId;
+
+ /**
+ * The command count. We need this because we need to not call stopSelfResult() while we still
+ * have commands running.
+ */
+ private int mCommandCount;
+
+ @Override
+ public void onCreate() {
+ mLastSeenStartId = 0;
+ mCommandCount = 0;
+ }
+
+ @Override
+ public void onDestroy() {
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ // This service cannot be bound
+ return null;
+ }
+
+ /**
+ * Executes an explicit command.
+ *
+ * This is the entry point for arbitrary commands that are executed upon reception of certain
+ * events that should be executed on the context of this service. The supported commands are:
+ * - Check last update time and possibly schedule an update of the data for later.
+ * This is triggered every day, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast.
+ * - Update data NOW.
+ * This is normally received upon trigger of the scheduled update.
+ * - Handle a finished download.
+ * This executes the actions that must be taken after a file (metadata or dictionary data
+ * has been downloaded (or failed to download).
+ */
+ @Override
+ public synchronized int onStartCommand(final Intent intent, final int flags,
+ final int startId) {
+ final DictionaryService self = this;
+ mLastSeenStartId = startId;
+ mCommandCount += 1;
+ if (SHOW_DOWNLOAD_TOAST_INTENT_ACTION.equals(intent.getAction())) {
+ // This is a UI action, it can't be run in another thread
+ showStartDownloadingToast(this, LocaleUtils.constructLocaleFromString(
+ intent.getStringExtra(LOCALE_INTENT_ARGUMENT)));
+ } else {
+ // If it's a command that does not require UI, create a thread to do the work
+ // and return right away. DATE_CHANGED or UPDATE_NOW are examples of such commands.
+ new Thread("updateOrFinishDownload") {
+ @Override
+ public void run() {
+ dispatchBroadcast(self, intent);
+ synchronized(self) {
+ if (--mCommandCount <= 0) {
+ if (!stopSelfResult(mLastSeenStartId)) {
+ Log.e(TAG, "Can't stop ourselves");
+ }
+ }
+ }
+ }
+ }.start();
+ }
+ return Service.START_REDELIVER_INTENT;
+ }
+
+ private static void dispatchBroadcast(final Context context, final Intent intent) {
+ if (DATE_CHANGED_INTENT_ACTION.equals(intent.getAction())) {
+ // This happens when the date of the device changes. This normally happens
+ // at midnight local time, but it may happen if the user changes the date
+ // by hand or something similar happens.
+ checkTimeAndMaybeSetupUpdateAlarm(context);
+ } else if (UPDATE_NOW_INTENT_ACTION.equals(intent.getAction())) {
+ // Intent to trigger an update now.
+ UpdateHandler.update(context, false);
+ } else {
+ UpdateHandler.downloadFinished(context, intent);
+ }
+ }
+
+ /**
+ * Setups an alarm to check for updates if an update is due.
+ */
+ private static void checkTimeAndMaybeSetupUpdateAlarm(final Context context) {
+ // Of all clients, if the one that hasn't been updated for the longest
+ // is still more recent than UPDATE_FREQUENCY, do nothing.
+ if (!isLastUpdateAtLeastThisOld(context, UPDATE_FREQUENCY)) return;
+
+ PrivateLog.log("Date changed - registering alarm", context);
+ AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
+
+ // Best effort to wake between midnight and MAX_ALARM_DELAY in the morning.
+ // It doesn't matter too much if this is very inexact.
+ final long now = System.currentTimeMillis();
+ final long alarmTime = now + new Random().nextInt(MAX_ALARM_DELAY);
+ final Intent updateIntent = new Intent(DictionaryService.UPDATE_NOW_INTENT_ACTION);
+ final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0,
+ updateIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+
+ // We set the alarm in the type that doesn't forcefully wake the device
+ // from sleep, but fires the next time the device actually wakes for any
+ // other reason.
+ if (null != alarmManager) alarmManager.set(AlarmManager.RTC, alarmTime, pendingIntent);
+ }
+
+ /**
+ * Utility method to decide whether the last update is older than a certain time.
+ *
+ * @return true if at least `time' milliseconds have elapsed since last update, false otherwise.
+ */
+ private static boolean isLastUpdateAtLeastThisOld(final Context context, final long time) {
+ final long now = System.currentTimeMillis();
+ final long lastUpdate = MetadataDbHelper.getOldestUpdateTime(context);
+ PrivateLog.log("Last update was " + lastUpdate, context);
+ return lastUpdate + time < now;
+ }
+
+ /**
+ * Refreshes data if it hasn't been refreshed in a very long time.
+ *
+ * This will check the last update time, and if it's been more than VERY_LONG_TIME,
+ * update metadata now - and possibly take subsequent update actions.
+ */
+ public static void updateNowIfNotUpdatedInAVeryLongTime(final Context context) {
+ if (!isLastUpdateAtLeastThisOld(context, VERY_LONG_TIME)) return;
+ UpdateHandler.update(context, false);
+ }
+
+ /**
+ * Shows a toast informing the user that an automatic dictionary download is starting.
+ */
+ private static void showStartDownloadingToast(final Context context, final Locale locale) {
+ final String toastText = String.format(
+ context.getString(R.string.toast_downloading_suggestions),
+ locale.getDisplayName());
+ Toast.makeText(context, toastText, Toast.LENGTH_LONG).show();
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsActivity.java b/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsActivity.java
new file mode 100644
index 000000000..684165240
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsActivity.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.preference.PreferenceActivity;
+
+/**
+ * Preference screen.
+ */
+public final class DictionarySettingsActivity extends PreferenceActivity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public Intent getIntent() {
+ final Intent modIntent = new Intent(super.getIntent());
+ modIntent.putExtra(EXTRA_SHOW_FRAGMENT, DictionarySettingsFragment.class.getName());
+ modIntent.putExtra(EXTRA_NO_HEADERS, true);
+ // Important note : the original intent should contain a String extra with the key
+ // DictionarySettingsFragment.DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT so that the
+ // fragment can know who the client is.
+ return modIntent;
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsFragment.java b/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsFragment.java
new file mode 100644
index 000000000..f5526ddd7
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsFragment.java
@@ -0,0 +1,365 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.Cursor;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceGroup;
+import android.text.format.DateUtils;
+import android.util.Log;
+import android.view.animation.AnimationUtils;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.inputmethod.latin.R;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.TreeMap;
+
+/**
+ * Preference screen.
+ */
+public final class DictionarySettingsFragment extends PreferenceFragment
+ implements UpdateHandler.UpdateEventListener {
+ private static final String TAG = DictionarySettingsFragment.class.getSimpleName();
+
+ static final private String DICT_LIST_ID = "list";
+ static final public String DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT = "clientId";
+
+ static final private int MENU_UPDATE_NOW = Menu.FIRST;
+
+ private View mLoadingView;
+ private String mClientId;
+ private ConnectivityManager mConnectivityManager;
+ private MenuItem mUpdateNowMenu;
+ private boolean mChangedSettings;
+
+ private final BroadcastReceiver mConnectivityChangedReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ refreshNetworkState();
+ }
+ };
+
+ /**
+ * Empty constructor for fragment generation.
+ */
+ public DictionarySettingsFragment() {
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View v = inflater.inflate(R.layout.loading_page, container, true);
+ mLoadingView = v.findViewById(R.id.loading_container);
+ return super.onCreateView(inflater, container, savedInstanceState);
+ }
+
+ @Override
+ public void onActivityCreated(final Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ final Activity activity = getActivity();
+ mClientId = activity.getIntent().getStringExtra(DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT);
+ mConnectivityManager =
+ (ConnectivityManager)activity.getSystemService(Context.CONNECTIVITY_SERVICE);
+ addPreferencesFromResource(R.xml.dictionary_settings);
+ refreshInterface();
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ mUpdateNowMenu = menu.add(Menu.NONE, MENU_UPDATE_NOW, 0, R.string.check_for_updates_now);
+ mUpdateNowMenu.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+ refreshNetworkState();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mChangedSettings = false;
+ UpdateHandler.registerUpdateEventListener(this);
+ final IntentFilter filter = new IntentFilter();
+ filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
+ getActivity().registerReceiver(mConnectivityChangedReceiver, filter);
+ refreshNetworkState();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ final Activity activity = getActivity();
+ UpdateHandler.unregisterUpdateEventListener(this);
+ activity.unregisterReceiver(mConnectivityChangedReceiver);
+ if (mChangedSettings) {
+ final Intent newDictBroadcast = new Intent(UpdateHandler.NEW_DICTIONARY_INTENT_ACTION);
+ activity.sendBroadcast(newDictBroadcast);
+ mChangedSettings = false;
+ }
+ }
+
+ public void downloadedMetadata(final boolean succeeded) {
+ stopLoadingAnimation();
+ if (!succeeded) return; // If the download failed nothing changed, so no need to refresh
+ new Thread("refreshInterface") {
+ @Override
+ public void run() {
+ refreshInterface();
+ }
+ }.start();
+ }
+
+ public void wordListDownloadFinished(final String wordListId, final boolean succeeded) {
+ final WordListPreference pref = findWordListPreference(wordListId);
+ if (null == pref) return;
+ // TODO: Report to the user if !succeeded
+ final Activity activity = getActivity();
+ if (null == activity) return;
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // We have to re-read the db in case the description has changed, and to
+ // find out what state it ended up if the download wasn't successful
+ // TODO: don't redo everything, only re-read and set this word list status
+ refreshInterface();
+ }
+ });
+ }
+
+ private WordListPreference findWordListPreference(final String id) {
+ final PreferenceGroup prefScreen = getPreferenceScreen();
+ if (null == prefScreen) {
+ Log.e(TAG, "Could not find the preference group");
+ return null;
+ }
+ for (int i = prefScreen.getPreferenceCount() - 1; i >= 0; --i) {
+ final Preference pref = prefScreen.getPreference(i);
+ if (pref instanceof WordListPreference) {
+ final WordListPreference wlPref = (WordListPreference)pref;
+ if (id.equals(wlPref.mWordlistId)) {
+ return wlPref;
+ }
+ }
+ }
+ Log.e(TAG, "Could not find the preference for a word list id " + id);
+ return null;
+ }
+
+ public void updateCycleCompleted() {}
+
+ private void refreshNetworkState() {
+ NetworkInfo info = mConnectivityManager.getActiveNetworkInfo();
+ boolean isConnected = null == info ? false : info.isConnected();
+ if (null != mUpdateNowMenu) mUpdateNowMenu.setEnabled(isConnected);
+ }
+
+ private void refreshInterface() {
+ final Activity activity = getActivity();
+ if (null == activity) return;
+ final long lastUpdateDate =
+ MetadataDbHelper.getLastUpdateDateForClient(getActivity(), mClientId);
+ final PreferenceGroup prefScreen = getPreferenceScreen();
+ final Collection extends Preference> prefList =
+ createInstalledDictSettingsCollection(mClientId);
+
+ final String updateNowSummary = getString(R.string.last_update) + " "
+ + DateUtils.formatDateTime(activity, lastUpdateDate,
+ DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME);
+
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // TODO: display this somewhere
+ // if (0 != lastUpdate) mUpdateNowPreference.setSummary(updateNowSummary);
+ refreshNetworkState();
+
+ removeAnyDictSettings(prefScreen);
+ for (Preference preference : prefList) {
+ prefScreen.addPreference(preference);
+ }
+ }
+ });
+ }
+
+ private Preference createErrorMessage(final Activity activity, final int messageResource) {
+ final Preference message = new Preference(activity);
+ message.setTitle(messageResource);
+ message.setEnabled(false);
+ return message;
+ }
+
+ private void removeAnyDictSettings(final PreferenceGroup prefGroup) {
+ for (int i = prefGroup.getPreferenceCount() - 1; i >= 0; --i) {
+ prefGroup.removePreference(prefGroup.getPreference(i));
+ }
+ }
+
+ /**
+ * Creates a WordListPreference list to be added to the screen.
+ *
+ * This method only creates the preferences but does not add them.
+ * Thus, it can be called on another thread.
+ *
+ * @param clientId the id of the client for which we want to display the dictionary list
+ * @return A collection of preferences ready to add to the interface.
+ */
+ private Collection extends Preference> createInstalledDictSettingsCollection(
+ final String clientId) {
+ // This will directly contact the DictionaryProvider and request the list exactly like
+ // any regular client would do.
+ // Considering the respective value of the respective constants used here for each path,
+ // segment, the url generated by this is of the form (assuming "clientId" as a clientId)
+ // content://com.android.inputmethod.latin.dictionarypack/clientId/list?procotol=2
+ final Uri contentUri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
+ .authority(getString(R.string.authority))
+ .appendPath(clientId)
+ .appendPath(DICT_LIST_ID)
+ // Need to use version 2 to get this client's list
+ .appendQueryParameter(DictionaryProvider.QUERY_PARAMETER_PROTOCOL_VERSION, "2")
+ .build();
+ final Activity activity = getActivity();
+ final Cursor cursor = null == activity ? null
+ : activity.getContentResolver().query(contentUri, null, null, null, null);
+
+ if (null == cursor) {
+ final ArrayList result = new ArrayList();
+ result.add(createErrorMessage(activity, R.string.cannot_connect_to_dict_service));
+ return result;
+ } else if (!cursor.moveToFirst()) {
+ final ArrayList result = new ArrayList();
+ result.add(createErrorMessage(activity, R.string.no_dictionaries_available));
+ return result;
+ } else {
+ final String systemLocaleString = Locale.getDefault().toString();
+ final TreeMap prefList =
+ new TreeMap();
+ final int idIndex = cursor.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN);
+ final int versionIndex = cursor.getColumnIndex(MetadataDbHelper.VERSION_COLUMN);
+ final int localeIndex = cursor.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN);
+ final int descriptionIndex = cursor.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN);
+ final int statusIndex = cursor.getColumnIndex(MetadataDbHelper.STATUS_COLUMN);
+ do {
+ final String wordlistId = cursor.getString(idIndex);
+ final int version = cursor.getInt(versionIndex);
+ final String localeString = cursor.getString(localeIndex);
+ final Locale locale = new Locale(localeString);
+ final String description = cursor.getString(descriptionIndex);
+ final int status = cursor.getInt(statusIndex);
+ final int matchLevel = LocaleUtils.getMatchLevel(systemLocaleString, localeString);
+ final String matchLevelString = LocaleUtils.getMatchLevelSortedString(matchLevel);
+ // The key is sorted in lexicographic order, according to the match level, then
+ // the description.
+ final String key = matchLevelString + "." + description + "." + wordlistId;
+ final WordListPreference existingPref = prefList.get(key);
+ if (null == existingPref || hasPriority(status, existingPref.mStatus)) {
+ final WordListPreference pref = new WordListPreference(activity, mClientId,
+ wordlistId, version, locale, description, status);
+ prefList.put(key, pref);
+ }
+ } while (cursor.moveToNext());
+ return prefList.values();
+ }
+ }
+
+ /**
+ * Finds out if a given status has priority over another for display order.
+ *
+ * @param newStatus
+ * @param oldStatus
+ * @return whether newStatus has priority over oldStatus.
+ */
+ private static boolean hasPriority(final int newStatus, final int oldStatus) {
+ // Both of these should be one of MetadataDbHelper.STATUS_*
+ return newStatus > oldStatus;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case MENU_UPDATE_NOW:
+ if (View.GONE == mLoadingView.getVisibility()) {
+ startRefresh();
+ } else {
+ cancelRefresh();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private void startRefresh() {
+ startLoadingAnimation();
+ mChangedSettings = true;
+ UpdateHandler.registerUpdateEventListener(this);
+ final Activity activity = getActivity();
+ new Thread("updateByHand") {
+ @Override
+ public void run() {
+ UpdateHandler.update(activity, true);
+ }
+ }.start();
+ }
+
+ private void cancelRefresh() {
+ UpdateHandler.unregisterUpdateEventListener(this);
+ final Context context = getActivity();
+ UpdateHandler.cancelUpdate(context,
+ MetadataDbHelper.getMetadataUriAsString(context, mClientId));
+ stopLoadingAnimation();
+ }
+
+ private void startLoadingAnimation() {
+ mLoadingView.setVisibility(View.VISIBLE);
+ getView().setVisibility(View.GONE);
+ mUpdateNowMenu.setTitle(R.string.cancel);
+ }
+
+ private void stopLoadingAnimation() {
+ final View preferenceView = getView();
+ final Activity activity = getActivity();
+ if (null == activity) return;
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mLoadingView.setVisibility(View.GONE);
+ preferenceView.setVisibility(View.VISIBLE);
+ mLoadingView.startAnimation(AnimationUtils.loadAnimation(
+ getActivity(), android.R.anim.fade_out));
+ preferenceView.startAnimation(AnimationUtils.loadAnimation(
+ getActivity(), android.R.anim.fade_in));
+ mUpdateNowMenu.setTitle(R.string.check_for_updates_now);
+ }
+ });
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DownloadOverMeteredDialog.java b/java/src/com/android/inputmethod/dictionarypack/DownloadOverMeteredDialog.java
new file mode 100644
index 000000000..d3c0a910f
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DownloadOverMeteredDialog.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Html;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import com.android.inputmethod.latin.R;
+
+import java.util.Locale;
+
+/**
+ * This implements the dialog for asking the user whether it's okay to download dictionaries over
+ * a metered connection or not (e.g. their mobile data plan).
+ */
+public final class DownloadOverMeteredDialog extends Activity {
+ final public static String CLIENT_ID_KEY = "client_id";
+ final public static String WORDLIST_TO_DOWNLOAD_KEY = "wordlist_to_download";
+ final public static String SIZE_KEY = "size";
+ final public static String LOCALE_KEY = "locale";
+ private String mClientId;
+ private String mWordListToDownload;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final Intent intent = getIntent();
+ mClientId = intent.getStringExtra(CLIENT_ID_KEY);
+ mWordListToDownload = intent.getStringExtra(WORDLIST_TO_DOWNLOAD_KEY);
+ final String localeString = intent.getStringExtra(LOCALE_KEY);
+ final long size = intent.getIntExtra(SIZE_KEY, 0);
+ setContentView(R.layout.download_over_metered);
+ setTexts(localeString, size);
+ }
+
+ private void setTexts(final String localeString, final long size) {
+ final String promptFormat = getString(R.string.should_download_over_metered_prompt);
+ final String allowButtonFormat = getString(R.string.download_over_metered);
+ final Locale locale = LocaleUtils.constructLocaleFromString(localeString);
+ final String language = (null == locale ? "" : locale.getDisplayLanguage());
+ final TextView prompt = (TextView)findViewById(R.id.download_over_metered_prompt);
+ prompt.setText(Html.fromHtml(String.format(promptFormat, language)));
+ final Button allowButton = (Button)findViewById(R.id.allow_button);
+ allowButton.setText(String.format(allowButtonFormat, ((float)size)/(1024*1024)));
+ }
+
+ public void onClickDeny(final View v) {
+ UpdateHandler.setDownloadOverMeteredSetting(this, false);
+ finish();
+ }
+
+ public void onClickAllow(final View v) {
+ UpdateHandler.setDownloadOverMeteredSetting(this, true);
+ UpdateHandler.installIfNeverRequested(this, mClientId, mWordListToDownload,
+ false /* mayPrompt */);
+ finish();
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DownloadRecord.java b/java/src/com/android/inputmethod/dictionarypack/DownloadRecord.java
new file mode 100644
index 000000000..c26299027
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DownloadRecord.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentValues;
+
+/**
+ * Struct class to encapsulate a client ID with content values about a download.
+ */
+public class DownloadRecord {
+ public final String mClientId;
+ // Only word lists have attributes, and the ContentValues should contain the same
+ // keys as they do for all MetadataDbHelper functions. Since only word lists have
+ // attributes, a null pointer here means this record represents metadata.
+ public final ContentValues mAttributes;
+ public DownloadRecord(final String clientId, final ContentValues attributes) {
+ mClientId = clientId;
+ mAttributes = attributes;
+ }
+ public boolean isMetadata() {
+ return null == mAttributes;
+ }
+}
\ No newline at end of file
diff --git a/java/src/com/android/inputmethod/dictionarypack/EventHandler.java b/java/src/com/android/inputmethod/dictionarypack/EventHandler.java
new file mode 100644
index 000000000..96c4a8305
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/EventHandler.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import com.android.inputmethod.latin.LatinIME;
+import com.android.inputmethod.latin.R;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+public final class EventHandler extends BroadcastReceiver {
+ private static final String TAG = EventHandler.class.getName();
+
+ /**
+ * Receives a intent broadcast.
+ *
+ * We receive every day a broadcast indicating that date changed.
+ * Then we wait a random amount of time before actually registering
+ * the download, to avoid concentrating too many accesses around
+ * midnight in more populated timezones.
+ * We receive all broadcasts here, so this can be either the DATE_CHANGED broadcast, the
+ * UPDATE_NOW private broadcast that we receive when the time-randomizing alarm triggers
+ * for regular update or from applications that want to test the dictionary pack, or a
+ * broadcast from DownloadManager telling that a download has finished.
+ * See inside of AndroidManifest.xml to see which events are caught.
+ * Also @see {@link BroadcastReceiver#onReceive(Context, Intent)}
+ *
+ * @param context the context of the application.
+ * @param intent the intent that was broadcast.
+ */
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ intent.setClass(context, DictionaryService.class);
+ context.startService(intent);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/LocaleUtils.java b/java/src/com/android/inputmethod/dictionarypack/LocaleUtils.java
new file mode 100644
index 000000000..d0e8446f5
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/LocaleUtils.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.text.TextUtils;
+
+import java.util.HashMap;
+import java.util.Locale;
+
+/**
+ * A class to help with handling Locales in string form.
+ *
+ * This file has the same meaning and features (and shares all of its code) with the one with the
+ * same name in Latin IME. They need to be kept synchronized; for any update/bugfix to
+ * this file, consider also updating/fixing the version in Latin IME.
+ */
+public final class LocaleUtils {
+ private LocaleUtils() {
+ // Intentional empty constructor for utility class.
+ }
+
+ // Locale match level constants.
+ // A higher level of match is guaranteed to have a higher numerical value.
+ // Some room is left within constants to add match cases that may arise necessary
+ // in the future, for example differentiating between the case where the countries
+ // are both present and different, and the case where one of the locales does not
+ // specify the countries. This difference is not needed now.
+
+ // Nothing matches.
+ public static final int LOCALE_NO_MATCH = 0;
+ // The languages matches, but the country are different. Or, the reference locale requires a
+ // country and the tested locale does not have one.
+ public static final int LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER = 3;
+ // The languages and country match, but the variants are different. Or, the reference locale
+ // requires a variant and the tested locale does not have one.
+ public static final int LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER = 6;
+ // The required locale is null or empty so it will accept anything, and the tested locale
+ // is non-null and non-empty.
+ public static final int LOCALE_ANY_MATCH = 10;
+ // The language matches, and the tested locale specifies a country but the reference locale
+ // does not require one.
+ public static final int LOCALE_LANGUAGE_MATCH = 15;
+ // The language and the country match, and the tested locale specifies a variant but the
+ // reference locale does not require one.
+ public static final int LOCALE_LANGUAGE_AND_COUNTRY_MATCH = 20;
+ // The compared locales are fully identical. This is the best match level.
+ public static final int LOCALE_FULL_MATCH = 30;
+
+ // The level at which a match is "normally" considered a locale match with standard algorithms.
+ // Don't use this directly, use #isMatch to test.
+ private static final int LOCALE_MATCH = LOCALE_ANY_MATCH;
+
+ // Make this match the maximum match level. If this evolves to have more than 2 digits
+ // when written in base 10, also adjust the getMatchLevelSortedString method.
+ private static final int MATCH_LEVEL_MAX = 30;
+
+ /**
+ * Return how well a tested locale matches a reference locale.
+ *
+ * This will check the tested locale against the reference locale and return a measure of how
+ * a well it matches the reference. The general idea is that the tested locale has to match
+ * every specified part of the required locale. A full match occur when they are equal, a
+ * partial match when the tested locale agrees with the reference locale but is more specific,
+ * and a difference when the tested locale does not comply with all requirements from the
+ * reference locale.
+ * In more detail, if the reference locale specifies at least a language and the testedLocale
+ * does not specify one, or specifies a different one, LOCALE_NO_MATCH is returned. If the
+ * reference locale is empty or null, it will match anything - in the form of LOCALE_FULL_MATCH
+ * if the tested locale is empty or null, and LOCALE_ANY_MATCH otherwise. If the reference and
+ * tested locale agree on the language, but not on the country,
+ * LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER is returned if the reference locale specifies a country,
+ * and LOCALE_LANGUAGE_MATCH otherwise.
+ * If they agree on both the language and the country, but not on the variant,
+ * LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER is returned if the reference locale
+ * specifies a variant, and LOCALE_LANGUAGE_AND_COUNTRY_MATCH otherwise. If everything matches,
+ * LOCALE_FULL_MATCH is returned.
+ * Examples:
+ * en <=> en_US => LOCALE_LANGUAGE_MATCH
+ * en_US <=> en => LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER
+ * en_US_POSIX <=> en_US_Android => LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER
+ * en_US <=> en_US_Android => LOCALE_LANGUAGE_AND_COUNTRY_MATCH
+ * sp_US <=> en_US => LOCALE_NO_MATCH
+ * de <=> de => LOCALE_FULL_MATCH
+ * en_US <=> en_US => LOCALE_FULL_MATCH
+ * "" <=> en_US => LOCALE_ANY_MATCH
+ *
+ * @param referenceLocale the reference locale to test against.
+ * @param testedLocale the locale to test.
+ * @return a constant that measures how well the tested locale matches the reference locale.
+ */
+ public static int getMatchLevel(final String referenceLocale, final String testedLocale) {
+ if (TextUtils.isEmpty(referenceLocale)) {
+ return TextUtils.isEmpty(testedLocale) ? LOCALE_FULL_MATCH : LOCALE_ANY_MATCH;
+ }
+ if (null == testedLocale) return LOCALE_NO_MATCH;
+ final String[] referenceParams = referenceLocale.split("_", 3);
+ final String[] testedParams = testedLocale.split("_", 3);
+ // By spec of String#split, [0] cannot be null and length cannot be 0.
+ if (!referenceParams[0].equals(testedParams[0])) return LOCALE_NO_MATCH;
+ switch (referenceParams.length) {
+ case 1:
+ return 1 == testedParams.length ? LOCALE_FULL_MATCH : LOCALE_LANGUAGE_MATCH;
+ case 2:
+ if (1 == testedParams.length) return LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER;
+ if (!referenceParams[1].equals(testedParams[1]))
+ return LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER;
+ if (3 == testedParams.length) return LOCALE_LANGUAGE_AND_COUNTRY_MATCH;
+ return LOCALE_FULL_MATCH;
+ case 3:
+ if (1 == testedParams.length) return LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER;
+ if (!referenceParams[1].equals(testedParams[1]))
+ return LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER;
+ if (2 == testedParams.length) return LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER;
+ if (!referenceParams[2].equals(testedParams[2]))
+ return LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER;
+ return LOCALE_FULL_MATCH;
+ }
+ // It should be impossible to come here
+ return LOCALE_NO_MATCH;
+ }
+
+ /**
+ * Return a string that represents this match level, with better matches first.
+ *
+ * The strings are sorted in lexicographic order: a better match will always be less than
+ * a worse match when compared together.
+ */
+ public static String getMatchLevelSortedString(final int matchLevel) {
+ // This works because the match levels are 0~99 (actually 0~30)
+ // Ideally this should use a number of digits equals to the 1og10 of the greater matchLevel
+ return String.format("%02d", MATCH_LEVEL_MAX - matchLevel);
+ }
+
+ /**
+ * Find out whether a match level should be considered a match.
+ *
+ * This method takes a match level as returned by the #getMatchLevel method, and returns whether
+ * it should be considered a match in the usual sense with standard Locale functions.
+ *
+ * @param level the match level, as returned by getMatchLevel.
+ * @return whether this is a match or not.
+ */
+ public static boolean isMatch(final int level) {
+ return LOCALE_MATCH <= level;
+ }
+
+ /**
+ * Sets the system locale for this process.
+ *
+ * @param res the resources to use. Pass current resources.
+ * @param newLocale the locale to change to.
+ * @return the old locale.
+ */
+ public static Locale setSystemLocale(final Resources res, final Locale newLocale) {
+ final Configuration conf = res.getConfiguration();
+ final Locale saveLocale = conf.locale;
+ conf.locale = newLocale;
+ res.updateConfiguration(conf, res.getDisplayMetrics());
+ return saveLocale;
+ }
+
+ private static final HashMap sLocaleCache = new HashMap();
+
+ /**
+ * Creates a locale from a string specification.
+ */
+ public static Locale constructLocaleFromString(final String localeStr) {
+ if (localeStr == null)
+ return null;
+ synchronized (sLocaleCache) {
+ if (sLocaleCache.containsKey(localeStr))
+ return sLocaleCache.get(localeStr);
+ Locale retval = null;
+ String[] localeParams = localeStr.split("_", 3);
+ if (localeParams.length == 1) {
+ retval = new Locale(localeParams[0]);
+ } else if (localeParams.length == 2) {
+ retval = new Locale(localeParams[0], localeParams[1]);
+ } else if (localeParams.length == 3) {
+ retval = new Locale(localeParams[0], localeParams[1], localeParams[2]);
+ }
+ if (retval != null) {
+ sLocaleCache.put(localeStr, retval);
+ }
+ return retval;
+ }
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/LogProblemReporter.java b/java/src/com/android/inputmethod/dictionarypack/LogProblemReporter.java
new file mode 100644
index 000000000..c127ad540
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/LogProblemReporter.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.util.Log;
+
+/**
+ * A very simple problem reporter.
+ */
+final class LogProblemReporter implements ProblemReporter {
+ private final String TAG;
+
+ public LogProblemReporter(final String tag) {
+ TAG = tag;
+ }
+
+ public void report(final Exception e) {
+ Log.e(TAG, "Reporting problem : " + e);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/MD5Calculator.java b/java/src/com/android/inputmethod/dictionarypack/MD5Calculator.java
new file mode 100644
index 000000000..e47e86e4b
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/MD5Calculator.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import java.io.InputStream;
+import java.io.IOException;
+import java.security.MessageDigest;
+
+final class MD5Calculator {
+ private MD5Calculator() {} // This helper class is not instantiable
+
+ public static String checksum(final InputStream in) throws IOException {
+ // This code from the Android documentation for MessageDigest. Nearly verbatim.
+ MessageDigest digester;
+ try {
+ digester = MessageDigest.getInstance("MD5");
+ } catch (java.security.NoSuchAlgorithmException e) {
+ return null; // Platform does not support MD5 : can't check, so return null
+ }
+ final byte[] bytes = new byte[8192];
+ int byteCount;
+ while ((byteCount = in.read(bytes)) > 0) {
+ digester.update(bytes, 0, byteCount);
+ }
+ final byte[] digest = digester.digest();
+ final StringBuilder s = new StringBuilder();
+ for (int i = 0; i < digest.length; ++i) {
+ s.append(String.format("%1$02x", digest[i]));
+ }
+ return s.toString();
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/MetadataDbHelper.java b/java/src/com/android/inputmethod/dictionarypack/MetadataDbHelper.java
new file mode 100644
index 000000000..55f545aad
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/MetadataDbHelper.java
@@ -0,0 +1,978 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.inputmethod.latin.R;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.TreeMap;
+
+/**
+ * Various helper functions for the state database
+ */
+public class MetadataDbHelper extends SQLiteOpenHelper {
+
+ @SuppressWarnings("unused")
+ private static final String TAG = MetadataDbHelper.class.getSimpleName();
+
+ // This was the initial release version of the database. It should never be
+ // changed going forward.
+ private static final int METADATA_DATABASE_INITIAL_VERSION = 3;
+ // This is the first released version of the database that implements CLIENTID. It is
+ // used to identify the versions for upgrades. This should never change going forward.
+ private static final int METADATA_DATABASE_VERSION_WITH_CLIENTID = 5;
+ // This is the current database version. It should be updated when the database schema
+ // gets updated. It is passed to the framework constructor of SQLiteOpenHelper, so
+ // that's what the framework uses to track our database version.
+ private static final int METADATA_DATABASE_VERSION = 5;
+
+ private final static long NOT_A_DOWNLOAD_ID = -1;
+
+ public static final String METADATA_TABLE_NAME = "pendingUpdates";
+ private static final String CLIENT_TABLE_NAME = "clients";
+ public static final String PENDINGID_COLUMN = "pendingid"; // Download Manager ID
+ public static final String TYPE_COLUMN = "type";
+ public static final String STATUS_COLUMN = "status";
+ public static final String LOCALE_COLUMN = "locale";
+ public static final String WORDLISTID_COLUMN = "id";
+ public static final String DESCRIPTION_COLUMN = "description";
+ public static final String LOCAL_FILENAME_COLUMN = "filename";
+ public static final String REMOTE_FILENAME_COLUMN = "url";
+ public static final String DATE_COLUMN = "date";
+ public static final String CHECKSUM_COLUMN = "checksum";
+ public static final String FILESIZE_COLUMN = "filesize";
+ public static final String VERSION_COLUMN = "version";
+ public static final String FORMATVERSION_COLUMN = "formatversion";
+ public static final String FLAGS_COLUMN = "flags";
+ public static final int COLUMN_COUNT = 13;
+
+ private static final String CLIENT_CLIENT_ID_COLUMN = "clientid";
+ private static final String CLIENT_METADATA_URI_COLUMN = "uri";
+ private static final String CLIENT_LAST_UPDATE_DATE_COLUMN = "lastupdate";
+ private static final String CLIENT_PENDINGID_COLUMN = "pendingid"; // Download Manager ID
+
+ public static final String METADATA_DATABASE_NAME_STEM = "pendingUpdates";
+ public static final String METADATA_UPDATE_DESCRIPTION = "metadata";
+
+ public static final String DICTIONARIES_ASSETS_PATH = "dictionaries";
+
+ // Statuses, for storing in the STATUS_COLUMN
+ // IMPORTANT: The following are used as index arrays in ../WordListPreference
+ // Do not change their values without updating the matched code.
+ // Unknown status: this should never happen.
+ public static final int STATUS_UNKNOWN = 0;
+ // Available: this word list is available, but it is not downloaded (not downloading), because
+ // it is set not to be used.
+ public static final int STATUS_AVAILABLE = 1;
+ // Downloading: this word list is being downloaded.
+ public static final int STATUS_DOWNLOADING = 2;
+ // Installed: this word list is installed and usable.
+ public static final int STATUS_INSTALLED = 3;
+ // Disabled: this word list is installed, but has been disabled by the user.
+ public static final int STATUS_DISABLED = 4;
+ // Deleting: the user marked this word list to be deleted, but it has not been yet because
+ // Latin IME is not up yet.
+ public static final int STATUS_DELETING = 5;
+
+ // Types, for storing in the TYPE_COLUMN
+ // This is metadata about what is available.
+ public static final int TYPE_METADATA = 1;
+ // This is a bulk file. It should replace older files.
+ public static final int TYPE_BULK = 2;
+ // This is an incremental update, expected to be small, and meaningless on its own.
+ public static final int TYPE_UPDATE = 3;
+
+ private static final String METADATA_TABLE_CREATE =
+ "CREATE TABLE " + METADATA_TABLE_NAME + " ("
+ + PENDINGID_COLUMN + " INTEGER, "
+ + TYPE_COLUMN + " INTEGER, "
+ + STATUS_COLUMN + " INTEGER, "
+ + WORDLISTID_COLUMN + " TEXT, "
+ + LOCALE_COLUMN + " TEXT, "
+ + DESCRIPTION_COLUMN + " TEXT, "
+ + LOCAL_FILENAME_COLUMN + " TEXT, "
+ + REMOTE_FILENAME_COLUMN + " TEXT, "
+ + DATE_COLUMN + " INTEGER, "
+ + CHECKSUM_COLUMN + " TEXT, "
+ + FILESIZE_COLUMN + " INTEGER, "
+ + VERSION_COLUMN + " INTEGER,"
+ + FORMATVERSION_COLUMN + " INTEGER,"
+ + FLAGS_COLUMN + " INTEGER,"
+ + "PRIMARY KEY (" + WORDLISTID_COLUMN + "," + VERSION_COLUMN + "));";
+ private static final String METADATA_CREATE_CLIENT_TABLE =
+ "CREATE TABLE IF NOT EXISTS " + CLIENT_TABLE_NAME + " ("
+ + CLIENT_CLIENT_ID_COLUMN + " TEXT, "
+ + CLIENT_METADATA_URI_COLUMN + " TEXT, "
+ + CLIENT_LAST_UPDATE_DATE_COLUMN + " INTEGER NOT NULL DEFAULT 0, "
+ + CLIENT_PENDINGID_COLUMN + " INTEGER, "
+ + FLAGS_COLUMN + " INTEGER, "
+ + "PRIMARY KEY (" + CLIENT_CLIENT_ID_COLUMN + "));";
+
+ // List of all metadata table columns.
+ static final String[] METADATA_TABLE_COLUMNS = { PENDINGID_COLUMN, TYPE_COLUMN,
+ STATUS_COLUMN, WORDLISTID_COLUMN, LOCALE_COLUMN, DESCRIPTION_COLUMN,
+ LOCAL_FILENAME_COLUMN, REMOTE_FILENAME_COLUMN, DATE_COLUMN, CHECKSUM_COLUMN,
+ FILESIZE_COLUMN, VERSION_COLUMN, FORMATVERSION_COLUMN, FLAGS_COLUMN };
+ // List of all client table columns.
+ static final String[] CLIENT_TABLE_COLUMNS = { CLIENT_CLIENT_ID_COLUMN,
+ CLIENT_METADATA_URI_COLUMN, CLIENT_PENDINGID_COLUMN, FLAGS_COLUMN };
+ // List of public columns returned to clients. Everything that is not in this list is
+ // private and implementation-dependent.
+ static final String[] DICTIONARIES_LIST_PUBLIC_COLUMNS = { STATUS_COLUMN, WORDLISTID_COLUMN,
+ LOCALE_COLUMN, DESCRIPTION_COLUMN, DATE_COLUMN, FILESIZE_COLUMN, VERSION_COLUMN };
+
+ // This class exhibits a singleton-like behavior by client ID, so it is getInstance'd
+ // and has a private c'tor.
+ private static TreeMap sInstanceMap = null;
+ public static synchronized MetadataDbHelper getInstance(final Context context,
+ final String clientIdOrNull) {
+ // As a backward compatibility feature, null can be passed here to retrieve the "default"
+ // database. Before multi-client support, the dictionary packed used only one database
+ // and would not be able to handle several dictionary sets. Passing null here retrieves
+ // this legacy database. New clients should make sure to always pass a client ID so as
+ // to avoid conflicts.
+ final String clientId = null != clientIdOrNull ? clientIdOrNull : "";
+ if (null == sInstanceMap) sInstanceMap = new TreeMap();
+ MetadataDbHelper helper = sInstanceMap.get(clientId);
+ if (null == helper) {
+ helper = new MetadataDbHelper(context, clientId);
+ sInstanceMap.put(clientId, helper);
+ }
+ return helper;
+ }
+ private MetadataDbHelper(final Context context, final String clientId) {
+ super(context,
+ METADATA_DATABASE_NAME_STEM + (TextUtils.isEmpty(clientId) ? "" : "." + clientId),
+ null, METADATA_DATABASE_VERSION);
+ mContext = context;
+ mClientId = clientId;
+ }
+
+ private final Context mContext;
+ private final String mClientId;
+
+ /**
+ * Get the database itself. This always returns the same object for any client ID. If the
+ * client ID is null, a default database is returned for backward compatibility. Don't
+ * pass null for new calls.
+ *
+ * @param context the context to create the database from. This is ignored after the first call.
+ * @param clientId the client id to retrieve the database of. null for default (deprecated)
+ * @return the database.
+ */
+ public static SQLiteDatabase getDb(final Context context, final String clientId) {
+ return getInstance(context, clientId).getWritableDatabase();
+ }
+
+ private void createClientTable(final SQLiteDatabase db) {
+ // The clients table only exists in the primary db, the one that has an empty client id
+ if (!TextUtils.isEmpty(mClientId)) return;
+ db.execSQL(METADATA_CREATE_CLIENT_TABLE);
+ final String defaultMetadataUri = mContext.getString(R.string.default_metadata_uri);
+ if (!TextUtils.isEmpty(defaultMetadataUri)) {
+ final ContentValues defaultMetadataValues = new ContentValues();
+ defaultMetadataValues.put(CLIENT_CLIENT_ID_COLUMN, "");
+ defaultMetadataValues.put(CLIENT_METADATA_URI_COLUMN, defaultMetadataUri);
+ db.insert(CLIENT_TABLE_NAME, null, defaultMetadataValues);
+ }
+ }
+
+ /**
+ * Create the table and populate it with the resources found inside the apk.
+ *
+ * @see SQLiteOpenHelper#onCreate(SQLiteDatabase)
+ *
+ * @param db the database to create and populate.
+ */
+ @Override
+ public void onCreate(final SQLiteDatabase db) {
+ db.execSQL(METADATA_TABLE_CREATE);
+ createClientTable(db);
+ }
+
+ /**
+ * Upgrade the database. Upgrade from version 3 is supported.
+ */
+ @Override
+ public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
+ if (METADATA_DATABASE_INITIAL_VERSION == oldVersion
+ && METADATA_DATABASE_VERSION_WITH_CLIENTID == newVersion) {
+ // Upgrade from version METADATA_DATABASE_INITIAL_VERSION to version
+ // METADATA_DATABASE_VERSION_WITH_CLIENT_ID
+ if (TextUtils.isEmpty(mClientId)) {
+ // Only the default database should contain the client table.
+ // Anyway in version 3 only the default table existed so the emptyness
+ // test should always be true, but better check to be sure.
+ createClientTable(db);
+ }
+ } else {
+ // Version 3 was the earliest version, so we should never come here. If we do, we
+ // have no idea what this database is, so we'd better wipe it off.
+ db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME);
+ db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME);
+ onCreate(db);
+ }
+ }
+
+ /**
+ * Downgrade the database. This drops and recreates the table in all cases.
+ */
+ @Override
+ public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
+ // No matter what the numerical values of oldVersion and newVersion are, we know this
+ // is a downgrade (newVersion < oldVersion). There is no way to know what the future
+ // databases will look like, but we know it's extremely likely that it's okay to just
+ // drop the tables and start from scratch. Hence, we ignore the versions and just wipe
+ // everything we want to use.
+ if (oldVersion <= newVersion) {
+ Log.e(TAG, "onDowngrade database but new version is higher? " + oldVersion + " <= "
+ + newVersion);
+ }
+ db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME);
+ db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME);
+ onCreate(db);
+ }
+
+ /**
+ * Given a client ID, returns whether this client exists.
+ *
+ * @param context a context to open the database
+ * @param clientId the client ID to check
+ * @return true if the client is known, false otherwise
+ */
+ public static boolean isClientKnown(final Context context, final String clientId) {
+ // If the client is known, they'll have a non-null metadata URI. An empty string is
+ // allowed as a metadata URI, if the client doesn't want any updates to happen.
+ return null != getMetadataUriAsString(context, clientId);
+ }
+
+ /**
+ * Returns the metadata URI as a string.
+ *
+ * If the client is not known, this will return null. If it is known, it will return
+ * the URI as a string. Note that the empty string is a valid value.
+ *
+ * @param context a context instance to open the database on
+ * @param clientId the ID of the client we want the metadata URI of
+ * @return the string representation of the URI
+ */
+ public static String getMetadataUriAsString(final Context context, final String clientId) {
+ SQLiteDatabase defaultDb = getDb(context, null);
+ final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
+ new String[] { CLIENT_METADATA_URI_COLUMN },
+ CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId },
+ null, null, null, null);
+ try {
+ if (!cursor.moveToFirst()) return null;
+ return cursor.getString(0); // Only one column, return it
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Update the last metadata update time for all clients using a particular URI.
+ *
+ * All clients using this metadata URI will be indicated as having been updated now.
+ * The current time is used as the latest update time. This saved date will be what
+ * is returned henceforth by {@link #getLastUpdateDateForClient(Context, String)},
+ * until this method is called again.
+ *
+ * @param context a context instance to open the database on
+ * @param uri the metadata URI we just downloaded
+ */
+ public static void saveLastUpdateTimeOfUri(final Context context, final String uri) {
+ PrivateLog.log("Save last update time of URI : " + uri + " " + System.currentTimeMillis(),
+ context);
+ final ContentValues values = new ContentValues();
+ values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis());
+ final SQLiteDatabase defaultDb = getDb(context, null);
+ defaultDb.update(CLIENT_TABLE_NAME, values,
+ CLIENT_METADATA_URI_COLUMN + " = ?", new String[] { uri });
+ }
+
+ /**
+ * Retrieves the last date at which we updated the metadata for this client.
+ *
+ * The returned date is in milliseconds from the EPOCH; this is the same unit as
+ * returned by {@link System#currentTimeMillis()}.
+ *
+ * @param context a context instance to open the database on
+ * @param clientId the client ID to get the latest update date of
+ * @return the last date at which this client was updated, as a long.
+ */
+ public static long getLastUpdateDateForClient(final Context context, final String clientId) {
+ SQLiteDatabase defaultDb = getDb(context, null);
+ final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
+ new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN },
+ CLIENT_CLIENT_ID_COLUMN + " = ?",
+ new String[] { null == clientId ? "" : clientId },
+ null, null, null, null);
+ try {
+ if (!cursor.moveToFirst()) return 0;
+ return cursor.getLong(0); // Only one column, return it
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Get the metadata download ID for a client ID.
+ *
+ * This will retrieve the download ID for the metadata file associated with a client ID.
+ * If there is no metadata download in progress for this client, it will return NOT_AN_ID.
+ *
+ * @param context a context instance to open the database on
+ * @param clientId the client ID to retrieve the metadata download ID of
+ * @return the metadata download ID, or NOT_AN_ID if no download is in progress
+ */
+ public static long getMetadataDownloadIdForClient(final Context context,
+ final String clientId) {
+ SQLiteDatabase defaultDb = getDb(context, null);
+ final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
+ new String[] { CLIENT_PENDINGID_COLUMN },
+ CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId },
+ null, null, null, null);
+ try {
+ if (!cursor.moveToFirst()) return UpdateHandler.NOT_AN_ID;
+ return cursor.getInt(0); // Only one column, return it
+ } finally {
+ cursor.close();
+ }
+ }
+
+ public static long getOldestUpdateTime(final Context context) {
+ SQLiteDatabase defaultDb = getDb(context, null);
+ final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
+ new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN },
+ null, null, null, null, null);
+ try {
+ if (!cursor.moveToFirst()) return 0;
+ final int columnIndex = 0; // Only one column queried
+ // Initialize the earliestTime to the largest possible value.
+ long earliestTime = Long.MAX_VALUE; // Almost 300 million years in the future
+ do {
+ final long thisTime = cursor.getLong(columnIndex);
+ earliestTime = Math.min(thisTime, earliestTime);
+ } while (cursor.moveToNext());
+ return earliestTime;
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Helper method to make content values to write into the database.
+ * @return content values with all the arguments put with the right column names.
+ */
+ public static ContentValues makeContentValues(final int pendingId, final int type,
+ final int status, final String wordlistId, final String locale,
+ final String description, final String filename, final String url, final long date,
+ final String checksum, final long filesize, final int version,
+ final int formatVersion) {
+ final ContentValues result = new ContentValues(COLUMN_COUNT);
+ result.put(PENDINGID_COLUMN, pendingId);
+ result.put(TYPE_COLUMN, type);
+ result.put(WORDLISTID_COLUMN, wordlistId);
+ result.put(STATUS_COLUMN, status);
+ result.put(LOCALE_COLUMN, locale);
+ result.put(DESCRIPTION_COLUMN, description);
+ result.put(LOCAL_FILENAME_COLUMN, filename);
+ result.put(REMOTE_FILENAME_COLUMN, url);
+ result.put(DATE_COLUMN, date);
+ result.put(CHECKSUM_COLUMN, checksum);
+ result.put(FILESIZE_COLUMN, filesize);
+ result.put(VERSION_COLUMN, version);
+ result.put(FORMATVERSION_COLUMN, formatVersion);
+ result.put(FLAGS_COLUMN, 0);
+ return result;
+ }
+
+ /**
+ * Helper method to fill in an incomplete ContentValues with default values.
+ * A wordlist ID and a locale are required, otherwise BadFormatException is thrown.
+ * @return the same object that was passed in, completed with default values.
+ */
+ public static ContentValues completeWithDefaultValues(final ContentValues result)
+ throws BadFormatException {
+ if (!result.containsKey(WORDLISTID_COLUMN) || !result.containsKey(LOCALE_COLUMN)) {
+ throw new BadFormatException();
+ }
+ // 0 for the pending id, because there is none
+ if (!result.containsKey(PENDINGID_COLUMN)) result.put(PENDINGID_COLUMN, 0);
+ // This is a binary blob of a dictionary
+ if (!result.containsKey(TYPE_COLUMN)) result.put(TYPE_COLUMN, TYPE_BULK);
+ // This word list is unknown, but it's present, else we wouldn't be here, so INSTALLED
+ if (!result.containsKey(STATUS_COLUMN)) result.put(STATUS_COLUMN, STATUS_INSTALLED);
+ // No description unless specified, because we can't guess it
+ if (!result.containsKey(DESCRIPTION_COLUMN)) result.put(DESCRIPTION_COLUMN, "");
+ // File name - this is an asset, so it works as an already deleted file.
+ // hence, we need to supply a non-existent file name. Anything will
+ // do as long as it returns false when tested with File#exist(), and
+ // the empty string does not, so it's set to "_".
+ if (!result.containsKey(LOCAL_FILENAME_COLUMN)) result.put(LOCAL_FILENAME_COLUMN, "_");
+ // No remote file name : this can't be downloaded. Unless specified.
+ if (!result.containsKey(REMOTE_FILENAME_COLUMN)) result.put(REMOTE_FILENAME_COLUMN, "");
+ // 0 for the update date : 1970/1/1. Unless specified.
+ if (!result.containsKey(DATE_COLUMN)) result.put(DATE_COLUMN, 0);
+ // Checksum unknown unless specified
+ if (!result.containsKey(CHECKSUM_COLUMN)) result.put(CHECKSUM_COLUMN, "");
+ // No filesize unless specified
+ if (!result.containsKey(FILESIZE_COLUMN)) result.put(FILESIZE_COLUMN, 0);
+ // Smallest possible version unless specified
+ if (!result.containsKey(VERSION_COLUMN)) result.put(VERSION_COLUMN, 1);
+ // Assume current format unless specified
+ if (!result.containsKey(FORMATVERSION_COLUMN))
+ result.put(FORMATVERSION_COLUMN, UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION);
+ // No flags unless specified
+ if (!result.containsKey(FLAGS_COLUMN)) result.put(FLAGS_COLUMN, 0);
+ return result;
+ }
+
+ /**
+ * Reads a column in a Cursor as a String and stores it in a ContentValues object.
+ * @param result the ContentValues object to store the result in.
+ * @param cursor the Cursor to read the column from.
+ * @param columnId the column ID to read.
+ */
+ private static void putStringResult(ContentValues result, Cursor cursor, String columnId) {
+ result.put(columnId, cursor.getString(cursor.getColumnIndex(columnId)));
+ }
+
+ /**
+ * Reads a column in a Cursor as an int and stores it in a ContentValues object.
+ * @param result the ContentValues object to store the result in.
+ * @param cursor the Cursor to read the column from.
+ * @param columnId the column ID to read.
+ */
+ private static void putIntResult(ContentValues result, Cursor cursor, String columnId) {
+ result.put(columnId, cursor.getInt(cursor.getColumnIndex(columnId)));
+ }
+
+ private static ContentValues getFirstLineAsContentValues(final Cursor cursor) {
+ final ContentValues result;
+ if (cursor.moveToFirst()) {
+ result = new ContentValues(COLUMN_COUNT);
+ putIntResult(result, cursor, PENDINGID_COLUMN);
+ putIntResult(result, cursor, TYPE_COLUMN);
+ putIntResult(result, cursor, STATUS_COLUMN);
+ putStringResult(result, cursor, WORDLISTID_COLUMN);
+ putStringResult(result, cursor, LOCALE_COLUMN);
+ putStringResult(result, cursor, DESCRIPTION_COLUMN);
+ putStringResult(result, cursor, LOCAL_FILENAME_COLUMN);
+ putStringResult(result, cursor, REMOTE_FILENAME_COLUMN);
+ putIntResult(result, cursor, DATE_COLUMN);
+ putStringResult(result, cursor, CHECKSUM_COLUMN);
+ putIntResult(result, cursor, FILESIZE_COLUMN);
+ putIntResult(result, cursor, VERSION_COLUMN);
+ putIntResult(result, cursor, FORMATVERSION_COLUMN);
+ putIntResult(result, cursor, FLAGS_COLUMN);
+ if (cursor.moveToNext()) {
+ // TODO: print the second level of the stack to the log so that we know
+ // in which code path the error happened
+ Log.e(TAG, "Several SQL results when we expected only one!");
+ }
+ } else {
+ result = null;
+ }
+ return result;
+ }
+
+ /**
+ * Gets the info about as specific download, indexed by its DownloadManager ID.
+ * @param db the database to get the information from.
+ * @param id the DownloadManager id.
+ * @return metadata about this download. This returns all columns in the database.
+ */
+ public static ContentValues getContentValuesByPendingId(final SQLiteDatabase db,
+ final long id) {
+ final Cursor cursor = db.query(METADATA_TABLE_NAME,
+ METADATA_TABLE_COLUMNS,
+ PENDINGID_COLUMN + "= ?",
+ new String[] { Long.toString(id) },
+ null, null, null);
+ // There should never be more than one result. If because of some bug there are, returning
+ // only one result is the right thing to do, because we couldn't handle several anyway
+ // and we should still handle one.
+ final ContentValues result = getFirstLineAsContentValues(cursor);
+ cursor.close();
+ return result;
+ }
+
+ /**
+ * Gets the info about an installed OR deleting word list with a specified id.
+ *
+ * Basically, this is the word list that we want to return to Android Keyboard when
+ * it asks for a specific id.
+ *
+ * @param db the database to get the information from.
+ * @param id the word list ID.
+ * @return the metadata about this word list.
+ */
+ public static ContentValues getInstalledOrDeletingWordListContentValuesByWordListId(
+ final SQLiteDatabase db, final String id) {
+ final Cursor cursor = db.query(METADATA_TABLE_NAME,
+ METADATA_TABLE_COLUMNS,
+ WORDLISTID_COLUMN + "=? AND (" + STATUS_COLUMN + "=? OR " + STATUS_COLUMN + "=?)",
+ new String[] { id, Integer.toString(STATUS_INSTALLED),
+ Integer.toString(STATUS_DELETING) },
+ null, null, null);
+ // There should only be one result, but if there are several, we can't tell which
+ // is the best, so we just return the first one.
+ final ContentValues result = getFirstLineAsContentValues(cursor);
+ cursor.close();
+ return result;
+ }
+
+ /**
+ * Given a specific download ID, return records for all pending downloads across all clients.
+ *
+ * If several clients use the same metadata URL, we know to only download it once, and
+ * dispatch the update process across all relevant clients when the download ends. This means
+ * several clients may share a single download ID if they share a metadata URI.
+ * The dispatching is done in {@link UpdateHandler#downloadFinished(Context, Intent)}, which
+ * finds out about the list of relevant clients by calling this method.
+ *
+ * @param context a context instance to open the databases
+ * @param downloadId the download ID to query about
+ * @return the list of records. Never null, but may be empty.
+ */
+ public static ArrayList getDownloadRecordsForDownloadId(final Context context,
+ final long downloadId) {
+ final SQLiteDatabase defaultDb = getDb(context, "");
+ final ArrayList results = new ArrayList();
+ final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, CLIENT_TABLE_COLUMNS,
+ null, null, null, null, null);
+ try {
+ if (!cursor.moveToFirst()) return results;
+ final int clientIdIndex = cursor.getColumnIndex(CLIENT_CLIENT_ID_COLUMN);
+ final int pendingIdColumn = cursor.getColumnIndex(CLIENT_PENDINGID_COLUMN);
+ do {
+ final long pendingId = cursor.getInt(pendingIdColumn);
+ final String clientId = cursor.getString(clientIdIndex);
+ if (pendingId == downloadId) {
+ results.add(new DownloadRecord(clientId, null));
+ }
+ final ContentValues valuesForThisClient =
+ getContentValuesByPendingId(getDb(context, clientId), downloadId);
+ if (null != valuesForThisClient) {
+ results.add(new DownloadRecord(clientId, valuesForThisClient));
+ }
+ } while (cursor.moveToNext());
+ } finally {
+ cursor.close();
+ }
+ return results;
+ }
+
+ /**
+ * Gets the info about a specific word list.
+ *
+ * @param db the database to get the information from.
+ * @param id the word list ID.
+ * @param version the word list version.
+ * @return the metadata about this word list.
+ */
+ public static ContentValues getContentValuesByWordListId(final SQLiteDatabase db,
+ final String id, final int version) {
+ final Cursor cursor = db.query(METADATA_TABLE_NAME,
+ METADATA_TABLE_COLUMNS,
+ WORDLISTID_COLUMN + "= ? AND " + VERSION_COLUMN + "= ?",
+ new String[] { id, Integer.toString(version) }, null, null, null);
+ // This is a lookup by primary key, so there can't be more than one result.
+ final ContentValues result = getFirstLineAsContentValues(cursor);
+ cursor.close();
+ return result;
+ }
+
+ /**
+ * Gets the info about the latest word list with an id.
+ *
+ * @param db the database to get the information from.
+ * @param id the word list ID.
+ * @return the metadata about the word list with this id and the latest version number.
+ */
+ public static ContentValues getContentValuesOfLatestAvailableWordlistById(
+ final SQLiteDatabase db, final String id) {
+ final Cursor cursor = db.query(METADATA_TABLE_NAME,
+ METADATA_TABLE_COLUMNS,
+ WORDLISTID_COLUMN + "= ?",
+ new String[] { id }, null, null, VERSION_COLUMN + " DESC", "1");
+ // This is a lookup by primary key, so there can't be more than one result.
+ final ContentValues result = getFirstLineAsContentValues(cursor);
+ cursor.close();
+ return result;
+ }
+
+ /**
+ * Gets the current metadata about INSTALLED, AVAILABLE or DELETING dictionaries.
+ *
+ * This odd method is tailored to the needs of
+ * DictionaryProvider#getDictionaryWordListsForContentUri, which needs the word list if
+ * it is:
+ * - INSTALLED: this should be returned to LatinIME if the file is still inside the dictionary
+ * pack, so that it can be copied. If the file is not there, it's been copied already and should
+ * not be returned, so getDictionaryWordListsForContentUri takes care of this.
+ * - DELETING: this should be returned to LatinIME so that it can actually delete the file.
+ * - AVAILABLE: this should not be returned, but should be checked for auto-installation.
+ *
+ * @param context the context for getting the database.
+ * @param clientId the client id for retrieving the database. null for default (deprecated)
+ * @return a cursor with metadata about usable dictionaries.
+ */
+ public static Cursor queryInstalledOrDeletingOrAvailableDictionaryMetadata(
+ final Context context, final String clientId) {
+ // If clientId is null, we get the defaut DB (see #getInstance() for more about this)
+ final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME,
+ METADATA_TABLE_COLUMNS,
+ STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ?",
+ new String[] { Integer.toString(STATUS_INSTALLED),
+ Integer.toString(STATUS_DELETING),
+ Integer.toString(STATUS_AVAILABLE) },
+ null, null, LOCALE_COLUMN);
+ return results;
+ }
+
+ /**
+ * Gets the current metadata about all dictionaries.
+ *
+ * This will retrieve the metadata about all dictionaries, including
+ * older files, or files not yet downloaded.
+ *
+ * @param context the context for getting the database.
+ * @param clientId the client id for retrieving the database. null for default (deprecated)
+ * @return a cursor with metadata about usable dictionaries.
+ */
+ public static Cursor queryCurrentMetadata(final Context context, final String clientId) {
+ // If clientId is null, we get the defaut DB (see #getInstance() for more about this)
+ final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME,
+ METADATA_TABLE_COLUMNS, null, null, null, null, LOCALE_COLUMN);
+ return results;
+ }
+
+ /**
+ * Gets the list of all dictionaries known to the dictionary provider, with only public columns.
+ *
+ * This will retrieve information about all known dictionaries, and their status. As such,
+ * it will also return information about dictionaries on the server that have not been
+ * downloaded yet, but may be requested.
+ * This only returns public columns. It does not populate internal columns in the returned
+ * cursor.
+ * The value returned by this method is intended to be good to be returned directly for a
+ * request of the list of dictionaries by a client.
+ *
+ * @param context the context to read the database from.
+ * @param clientId the client id for retrieving the database. null for default (deprecated)
+ * @return a cursor that lists all available dictionaries and their metadata.
+ */
+ public static Cursor queryDictionaries(final Context context, final String clientId) {
+ // If clientId is null, we get the defaut DB (see #getInstance() for more about this)
+ final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME,
+ DICTIONARIES_LIST_PUBLIC_COLUMNS,
+ // Filter out empty locales so as not to return auxiliary data, like a
+ // data line for downloading metadata:
+ MetadataDbHelper.LOCALE_COLUMN + " != ?", new String[] {""},
+ // TODO: Reinstate the following code for bulk, then implement partial updates
+ /* MetadataDbHelper.TYPE_COLUMN + " = ?",
+ new String[] { Integer.toString(MetadataDbHelper.TYPE_BULK) }, */
+ null, null, LOCALE_COLUMN);
+ return results;
+ }
+
+ /**
+ * Deletes all data associated with a client.
+ *
+ * @param context the context for opening the database
+ * @param clientId the ID of the client to delete.
+ * @return true if the client was successfully deleted, false otherwise.
+ */
+ public static boolean deleteClient(final Context context, final String clientId) {
+ // Remove all metadata associated with this client
+ final SQLiteDatabase db = getDb(context, clientId);
+ db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME);
+ db.execSQL(METADATA_TABLE_CREATE);
+ // Remove this client's entry in the clients table
+ final SQLiteDatabase defaultDb = getDb(context, "");
+ if (0 == defaultDb.delete(CLIENT_TABLE_NAME,
+ CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId })) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Updates information relative to a specific client.
+ *
+ * Updatable information includes only the metadata URI, but may be expanded in the future.
+ * The passed values must include a client ID in the key CLIENT_CLIENT_ID_COLUMN, and it must
+ * be equal to the string passed as an argument for clientId.
+ * The passed values must also include a non-empty metadata URI in the
+ * CLIENT_METADATA_URI_COLUMN column.
+ * If any of the above is not complied with, this function returns without updating data.
+ *
+ * @param context the context, to open the database
+ * @param clientId the ID of the client to update
+ * @param values the values to update. Must conform to the protocol (see above)
+ */
+ public static void updateClientInfo(final Context context, final String clientId,
+ final ContentValues values) {
+ // Sanity check the content values
+ final String valuesClientId = values.getAsString(CLIENT_CLIENT_ID_COLUMN);
+ final String valuesMetadataUri = values.getAsString(CLIENT_METADATA_URI_COLUMN);
+ // Empty string is a valid client ID, but external apps may not configure it.
+ // Empty string is a valid metadata URI if the client does not want updates.
+ if (TextUtils.isEmpty(valuesClientId) || null == valuesMetadataUri) {
+ // We need both these columns to be filled in
+ Utils.l("Missing parameter for updateClientInfo");
+ return;
+ }
+ if (!clientId.equals(valuesClientId)) {
+ // Mismatch! The client violates the protocol.
+ Utils.l("Received an updateClientInfo request for ", clientId, " but the values "
+ + "contain a different ID : ", valuesClientId);
+ return;
+ }
+ final SQLiteDatabase defaultDb = getDb(context, "");
+ if (-1 == defaultDb.insert(CLIENT_TABLE_NAME, null, values)) {
+ defaultDb.update(CLIENT_TABLE_NAME, values,
+ CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId });
+ }
+ }
+
+ /**
+ * Retrieves the list of existing client IDs.
+ * @param context the context to open the database
+ * @return a cursor containing only one column, and one client ID per line.
+ */
+ public static Cursor queryClientIds(final Context context) {
+ return getDb(context, null).query(CLIENT_TABLE_NAME,
+ new String[] { CLIENT_CLIENT_ID_COLUMN }, null, null, null, null, null);
+ }
+
+ /**
+ * Register a download ID for a specific metadata URI.
+ *
+ * This method should be called when a download for a metadata URI is starting. It will
+ * register the download ID for all clients using this metadata URI into the database
+ * for later retrieval by {@link #getDownloadRecordsForDownloadId(Context, long)}.
+ *
+ * @param context a context for opening databases
+ * @param uri the metadata URI
+ * @param downloadId the download ID
+ */
+ public static void registerMetadataDownloadId(final Context context, final String uri,
+ final long downloadId) {
+ final ContentValues values = new ContentValues();
+ values.put(CLIENT_PENDINGID_COLUMN, downloadId);
+ final SQLiteDatabase defaultDb = getDb(context, "");
+ defaultDb.update(CLIENT_TABLE_NAME, values,
+ CLIENT_METADATA_URI_COLUMN + " = ?", new String[] { uri });
+ }
+
+ /**
+ * Marks a downloading entry as having successfully downloaded and being installed.
+ *
+ * The metadata database contains information about ongoing processes, typically ongoing
+ * downloads. This marks such an entry as having finished and having installed successfully,
+ * so it becomes INSTALLED.
+ *
+ * @param db the metadata database.
+ * @param r content values about the entry to mark as processed.
+ */
+ public static void markEntryAsFinishedDownloadingAndInstalled(final SQLiteDatabase db,
+ final ContentValues r) {
+ switch (r.getAsInteger(TYPE_COLUMN)) {
+ case TYPE_BULK:
+ Utils.l("Ended processing a wordlist");
+ // Updating a bulk word list is a three-step operation:
+ // - Add the new entry to the table
+ // - Remove the old entry from the table
+ // - Erase the old file
+ // We start by gathering the names of the files we should delete.
+ final List filenames = new LinkedList();
+ final Cursor c = db.query(METADATA_TABLE_NAME,
+ new String[] { LOCAL_FILENAME_COLUMN },
+ LOCALE_COLUMN + " = ? AND " +
+ WORDLISTID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?",
+ new String[] { r.getAsString(LOCALE_COLUMN),
+ r.getAsString(WORDLISTID_COLUMN),
+ Integer.toString(STATUS_INSTALLED) },
+ null, null, null);
+ if (c.moveToFirst()) {
+ // There should never be more than one file, but if there are, it's a bug
+ // and we should remove them all. I think it might happen if the power of the
+ // phone is suddenly cut during an update.
+ final int filenameIndex = c.getColumnIndex(LOCAL_FILENAME_COLUMN);
+ do {
+ Utils.l("Setting for removal", c.getString(filenameIndex));
+ filenames.add(c.getString(filenameIndex));
+ } while (c.moveToNext());
+ }
+
+ r.put(STATUS_COLUMN, STATUS_INSTALLED);
+ db.beginTransactionNonExclusive();
+ // Delete all old entries. There should never be any stalled entries, but if
+ // there are, this deletes them.
+ db.delete(METADATA_TABLE_NAME,
+ WORDLISTID_COLUMN + " = ?",
+ new String[] { r.getAsString(WORDLISTID_COLUMN) });
+ db.insert(METADATA_TABLE_NAME, null, r);
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ for (String filename : filenames) {
+ try {
+ final File f = new File(filename);
+ f.delete();
+ } catch (SecurityException e) {
+ // No permissions to delete. Um. Can't do anything.
+ } // I don't think anything else can be thrown
+ }
+ break;
+ default:
+ // Unknown type: do nothing.
+ break;
+ }
+ }
+
+ /**
+ * Removes a downloading entry from the database.
+ *
+ * This is invoked when a download fails. Either we tried to download, but
+ * we received a permanent failure and we should remove it, or we got manually
+ * cancelled and we should leave it at that.
+ *
+ * @param db the metadata database.
+ * @param id the DownloadManager id of the file.
+ */
+ public static void deleteDownloadingEntry(final SQLiteDatabase db, final long id) {
+ db.delete(METADATA_TABLE_NAME, PENDINGID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?",
+ new String[] { Long.toString(id), Integer.toString(STATUS_DOWNLOADING) });
+ }
+
+ /**
+ * Forcefully removes an entry from the database.
+ *
+ * This is invoked when a file is broken. The file has been downloaded, but Android
+ * Keyboard is telling us it could not open it.
+ *
+ * @param db the metadata database.
+ * @param id the id of the word list.
+ * @param version the version of the word list.
+ */
+ public static void deleteEntry(final SQLiteDatabase db, final String id, final int version) {
+ db.delete(METADATA_TABLE_NAME, WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?",
+ new String[] { id, Integer.toString(version) });
+ }
+
+ /**
+ * Internal method that sets the current status of an entry of the database.
+ *
+ * @param db the metadata database.
+ * @param id the id of the word list.
+ * @param version the version of the word list.
+ * @param status the status to set the word list to.
+ * @param downloadId an optional download id to write, or NOT_A_DOWNLOAD_ID
+ */
+ private static void markEntryAs(final SQLiteDatabase db, final String id,
+ final int version, final int status, final long downloadId) {
+ final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version);
+ values.put(STATUS_COLUMN, status);
+ if (NOT_A_DOWNLOAD_ID != downloadId) {
+ values.put(MetadataDbHelper.PENDINGID_COLUMN, downloadId);
+ }
+ db.update(METADATA_TABLE_NAME, values,
+ WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?",
+ new String[] { id, Integer.toString(version) });
+ }
+
+ /**
+ * Writes the status column for the wordlist with this id as enabled. Typically this
+ * means the word list is currently disabled and we want to set its status to INSTALLED.
+ *
+ * @param db the metadata database.
+ * @param id the id of the word list.
+ * @param version the version of the word list.
+ */
+ public static void markEntryAsEnabled(final SQLiteDatabase db, final String id,
+ final int version) {
+ markEntryAs(db, id, version, STATUS_INSTALLED, NOT_A_DOWNLOAD_ID);
+ }
+
+ /**
+ * Writes the status column for the wordlist with this id as disabled. Typically this
+ * means the word list is currently installed and we want to set its status to DISABLED.
+ *
+ * @param db the metadata database.
+ * @param id the id of the word list.
+ * @param version the version of the word list.
+ */
+ public static void markEntryAsDisabled(final SQLiteDatabase db, final String id,
+ final int version) {
+ markEntryAs(db, id, version, STATUS_DISABLED, NOT_A_DOWNLOAD_ID);
+ }
+
+ /**
+ * Writes the status column for the wordlist with this id as available. This happens for
+ * example when a word list has been deleted but can be downloaded again.
+ *
+ * @param db the metadata database.
+ * @param id the id of the word list.
+ * @param version the version of the word list.
+ */
+ public static void markEntryAsAvailable(final SQLiteDatabase db, final String id,
+ final int version) {
+ markEntryAs(db, id, version, STATUS_AVAILABLE, NOT_A_DOWNLOAD_ID);
+ }
+
+ /**
+ * Writes the designated word list as downloadable, alongside with its download id.
+ *
+ * @param db the metadata database.
+ * @param id the id of the word list.
+ * @param version the version of the word list.
+ * @param downloadId the download id.
+ */
+ public static void markEntryAsDownloading(final SQLiteDatabase db, final String id,
+ final int version, final long downloadId) {
+ markEntryAs(db, id, version, STATUS_DOWNLOADING, downloadId);
+ }
+
+ /**
+ * Writes the designated word list as deleting.
+ *
+ * @param db the metadata database.
+ * @param id the id of the word list.
+ * @param version the version of the word list.
+ */
+ public static void markEntryAsDeleting(final SQLiteDatabase db, final String id,
+ final int version) {
+ markEntryAs(db, id, version, STATUS_DELETING, NOT_A_DOWNLOAD_ID);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/MetadataHandler.java b/java/src/com/android/inputmethod/dictionarypack/MetadataHandler.java
new file mode 100644
index 000000000..a0147b6d6
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/MetadataHandler.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.Context;
+import android.database.Cursor;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.Collections;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helper class to easy up manipulation of dictionary pack metadata.
+ */
+public class MetadataHandler {
+ @SuppressWarnings("unused")
+ private static final String TAG = "DictionaryProvider:" + MetadataHandler.class.getSimpleName();
+
+ // The canonical file name for metadata. This is not the name of a real file on the
+ // device, but a symbolic name used in the database and in metadata handling. It is never
+ // tested against, only used for human-readability as the file name for the metadata.
+ public final static String METADATA_FILENAME = "metadata.json";
+
+ /**
+ * Reads the data from the cursor and store it in metadata objects.
+ * @param results the cursor to read data from.
+ * @return the constructed list of wordlist metadata.
+ */
+ private static List makeMetadataObject(final Cursor results) {
+ final ArrayList buildingMetadata = new ArrayList();
+
+ if (results.moveToFirst()) {
+ final int localeColumn = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN);
+ final int typeColumn = results.getColumnIndex(MetadataDbHelper.TYPE_COLUMN);
+ final int descriptionColumn =
+ results.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN);
+ final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN);
+ final int updateIndex = results.getColumnIndex(MetadataDbHelper.DATE_COLUMN);
+ final int fileSizeIndex = results.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN);
+ final int checksumIndex = results.getColumnIndex(MetadataDbHelper.CHECKSUM_COLUMN);
+ final int localFilenameIndex =
+ results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
+ final int remoteFilenameIndex =
+ results.getColumnIndex(MetadataDbHelper.REMOTE_FILENAME_COLUMN);
+ final int versionIndex = results.getColumnIndex(MetadataDbHelper.VERSION_COLUMN);
+ final int formatVersionIndex =
+ results.getColumnIndex(MetadataDbHelper.FORMATVERSION_COLUMN);
+
+ do {
+ buildingMetadata.add(new WordListMetadata(results.getString(idIndex),
+ results.getInt(typeColumn),
+ results.getString(descriptionColumn),
+ results.getLong(updateIndex),
+ results.getLong(fileSizeIndex),
+ results.getString(checksumIndex),
+ results.getString(localFilenameIndex),
+ results.getString(remoteFilenameIndex),
+ results.getInt(versionIndex),
+ results.getInt(formatVersionIndex),
+ 0, results.getString(localeColumn)));
+ } while (results.moveToNext());
+
+ results.close();
+ }
+ return Collections.unmodifiableList(buildingMetadata);
+ }
+
+ /**
+ * Gets the whole metadata, for installed and not installed dictionaries.
+ * @param context The context to open files over.
+ * @param clientId the client id for retrieving the database. null for default (deprecated)
+ * @return The current metadata.
+ */
+ public static List getCurrentMetadata(final Context context,
+ final String clientId) {
+ // If clientId is null, we get a cursor on the default database (see
+ // MetadataDbHelper#getInstance() for more on this)
+ final Cursor results = MetadataDbHelper.queryCurrentMetadata(context, clientId);
+ final List resultList = makeMetadataObject(results);
+ results.close();
+ return resultList;
+ }
+
+ /**
+ * Read metadata from a stream.
+ * @param input The stream to read from.
+ * @return The read metadata.
+ * @throws IOException if the input stream cannot be read
+ * @throws BadFormatException if the stream is not in a known format
+ */
+ public static List readMetadata(final InputStreamReader input)
+ throws IOException, BadFormatException {
+ return MetadataParser.parseMetadata(input);
+ }
+
+ /**
+ * Finds a single WordListMetadata inside a whole metadata chunk.
+ *
+ * Searches through the whole passed metadata for the first WordListMetadata associated
+ * with the passed ID. If several metadata chunks with the same id are found, it will
+ * always return the one with the bigger FormatVersion that is less or equal than the
+ * maximum supported format version (as listed in UpdateHandler).
+ * This will NEVER return the metadata with a FormatVersion bigger than what is supported,
+ * even if it is the only word list with this ID.
+ *
+ * @param metadata the metadata to search into.
+ * @param id the word list ID of the metadata to find.
+ * @return the associated metadata, or null if not found.
+ */
+ public static WordListMetadata findWordListById(final List metadata,
+ final String id) {
+ WordListMetadata bestWordList = null;
+ int bestFormatVersion = Integer.MIN_VALUE; // To be sure we can't be inadvertently smaller
+ for (WordListMetadata wordList : metadata) {
+ if (id.equals(wordList.mId)
+ && wordList.mFormatVersion <= UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION
+ && wordList.mFormatVersion > bestFormatVersion) {
+ bestWordList = wordList;
+ bestFormatVersion = wordList.mFormatVersion;
+ }
+ }
+ // If we didn't find any match we'll return null.
+ return bestWordList;
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/MetadataParser.java b/java/src/com/android/inputmethod/dictionarypack/MetadataParser.java
new file mode 100644
index 000000000..27670fddf
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/MetadataParser.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.text.TextUtils;
+import android.util.JsonReader;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.TreeMap;
+
+/**
+ * Helper class containing functions to parse the dictionary metadata.
+ */
+public class MetadataParser {
+
+ // Name of the fields in the JSON-formatted file.
+ private static final String ID_FIELD_NAME = MetadataDbHelper.WORDLISTID_COLUMN;
+ private static final String LOCALE_FIELD_NAME = "locale";
+ private static final String DESCRIPTION_FIELD_NAME = MetadataDbHelper.DESCRIPTION_COLUMN;
+ private static final String UPDATE_FIELD_NAME = "update";
+ private static final String FILESIZE_FIELD_NAME = MetadataDbHelper.FILESIZE_COLUMN;
+ private static final String CHECKSUM_FIELD_NAME = MetadataDbHelper.CHECKSUM_COLUMN;
+ private static final String REMOTE_FILENAME_FIELD_NAME =
+ MetadataDbHelper.REMOTE_FILENAME_COLUMN;
+ private static final String VERSION_FIELD_NAME = MetadataDbHelper.VERSION_COLUMN;
+ private static final String FORMATVERSION_FIELD_NAME = MetadataDbHelper.FORMATVERSION_COLUMN;
+
+ /**
+ * Parse one JSON-formatted word list metadata.
+ * @param reader the reader containing the data.
+ * @return a WordListMetadata object from the parsed data.
+ * @throws IOException if the underlying reader throws IOException during reading.
+ */
+ private static WordListMetadata parseOneWordList(final JsonReader reader)
+ throws IOException, BadFormatException {
+ final TreeMap arguments = new TreeMap();
+ reader.beginObject();
+ while (reader.hasNext()) {
+ final String name = reader.nextName();
+ if (!TextUtils.isEmpty(name)) {
+ arguments.put(name, reader.nextString());
+ }
+ }
+ reader.endObject();
+ if (TextUtils.isEmpty(arguments.get(ID_FIELD_NAME))
+ || TextUtils.isEmpty(arguments.get(LOCALE_FIELD_NAME))
+ || TextUtils.isEmpty(arguments.get(DESCRIPTION_FIELD_NAME))
+ || TextUtils.isEmpty(arguments.get(UPDATE_FIELD_NAME))
+ || TextUtils.isEmpty(arguments.get(FILESIZE_FIELD_NAME))
+ || TextUtils.isEmpty(arguments.get(CHECKSUM_FIELD_NAME))
+ || TextUtils.isEmpty(arguments.get(REMOTE_FILENAME_FIELD_NAME))
+ || TextUtils.isEmpty(arguments.get(VERSION_FIELD_NAME))
+ || TextUtils.isEmpty(arguments.get(FORMATVERSION_FIELD_NAME))) {
+ throw new BadFormatException(arguments.toString());
+ }
+ // TODO: need to find out whether it's bulk or update
+ // The null argument is the local file name, which is not known at this time and will
+ // be decided later.
+ return new WordListMetadata(
+ arguments.get(ID_FIELD_NAME),
+ MetadataDbHelper.TYPE_BULK,
+ arguments.get(DESCRIPTION_FIELD_NAME),
+ Long.parseLong(arguments.get(UPDATE_FIELD_NAME)),
+ Long.parseLong(arguments.get(FILESIZE_FIELD_NAME)),
+ arguments.get(CHECKSUM_FIELD_NAME),
+ null,
+ arguments.get(REMOTE_FILENAME_FIELD_NAME),
+ Integer.parseInt(arguments.get(VERSION_FIELD_NAME)),
+ Integer.parseInt(arguments.get(FORMATVERSION_FIELD_NAME)),
+ 0, arguments.get(LOCALE_FIELD_NAME));
+ }
+
+ /**
+ * Parses metadata in the JSON format.
+ * @param input a stream reader expected to contain JSON formatted metadata.
+ * @return dictionary metadata, as an array of WordListMetadata objects.
+ * @throws IOException if the underlying reader throws IOException during reading.
+ * @throws BadFormatException if the data was not in the expected format.
+ */
+ public static List parseMetadata(final InputStreamReader input)
+ throws IOException, BadFormatException {
+ JsonReader reader = new JsonReader(input);
+ final ArrayList readInfo = new ArrayList();
+ reader.beginArray();
+ while (reader.hasNext()) {
+ final WordListMetadata thisMetadata = parseOneWordList(reader);
+ if (!TextUtils.isEmpty(thisMetadata.mLocale))
+ readInfo.add(thisMetadata);
+ }
+ return Collections.unmodifiableList(readInfo);
+ }
+
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/PrivateLog.java b/java/src/com/android/inputmethod/dictionarypack/PrivateLog.java
new file mode 100644
index 000000000..8593c1c9b
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/PrivateLog.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Class to keep long-term log. This is inactive in production, and is only for debug purposes.
+ */
+public class PrivateLog {
+
+ public static final boolean DEBUG = DictionaryProvider.DEBUG;
+
+ private static final String LOG_DATABASE_NAME = "log";
+ private static final String LOG_TABLE_NAME = "log";
+ private static final int LOG_DATABASE_VERSION = 1;
+
+ private static final String COLUMN_DATE = "date";
+ private static final String COLUMN_EVENT = "event";
+
+ private static final String LOG_TABLE_CREATE = "CREATE TABLE " + LOG_TABLE_NAME + " ("
+ + COLUMN_DATE + " TEXT,"
+ + COLUMN_EVENT + " TEXT);";
+
+ private static final SimpleDateFormat sDateFormat =
+ new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
+
+ private static PrivateLog sInstance = new PrivateLog();
+ private static DebugHelper mDebugHelper = null;
+
+ private PrivateLog() {
+ }
+
+ public static synchronized PrivateLog getInstance(final Context context) {
+ if (!DEBUG) return sInstance;
+ synchronized(PrivateLog.class) {
+ if (sInstance.mDebugHelper == null) {
+ sInstance.mDebugHelper = new DebugHelper(context);
+ }
+ return sInstance;
+ }
+ }
+
+ private static class DebugHelper extends SQLiteOpenHelper {
+
+ private DebugHelper(final Context context) {
+ super(context, LOG_DATABASE_NAME, null, LOG_DATABASE_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ if (!DEBUG) return;
+ db.execSQL(LOG_TABLE_CREATE);
+ insert(db, "Created table");
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ if (!DEBUG) return;
+ // Remove all data.
+ db.execSQL("DROP TABLE IF EXISTS " + LOG_TABLE_NAME);
+ onCreate(db);
+ insert(db, "Upgrade finished");
+ }
+
+ private static void insert(SQLiteDatabase db, String event) {
+ if (!DEBUG) return;
+ final ContentValues c = new ContentValues(2);
+ c.put(COLUMN_DATE, sDateFormat.format(new Date(System.currentTimeMillis())));
+ c.put(COLUMN_EVENT, event);
+ db.insert(LOG_TABLE_NAME, null, c);
+ }
+
+ }
+
+ public static void log(String event, Context context) {
+ if (!DEBUG) return;
+ final SQLiteDatabase l = getInstance(context).mDebugHelper.getWritableDatabase();
+ mDebugHelper.insert(l, event);
+ }
+
+ public static void log(String event, ContentProvider provider) {
+ if (!DEBUG) return;
+ final SQLiteDatabase l =
+ getInstance(provider.getContext()).mDebugHelper.getWritableDatabase();
+ mDebugHelper.insert(l, event);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/ProblemReporter.java b/java/src/com/android/inputmethod/dictionarypack/ProblemReporter.java
new file mode 100644
index 000000000..632819aa3
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/ProblemReporter.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+/**
+ * A simple interface to report problems.
+ */
+public interface ProblemReporter {
+ public void report(Exception e);
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java b/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java
new file mode 100644
index 000000000..89cf6ed88
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java
@@ -0,0 +1,1088 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.DownloadManager;
+import android.app.DownloadManager.Query;
+import android.app.DownloadManager.Request;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.ConnectivityManager;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.inputmethod.compat.ConnectivityManagerCompatUtils;
+import com.android.inputmethod.compat.DownloadManagerCompatUtils;
+import com.android.inputmethod.latin.R;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.channels.FileChannel;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Handler for the update process.
+ *
+ * This class is in charge of coordinating the update process for the various dictionaries
+ * stored in the dictionary pack.
+ */
+public final class UpdateHandler {
+ static final String TAG = "DictionaryProvider:" + UpdateHandler.class.getSimpleName();
+ private static final boolean DEBUG = DictionaryProvider.DEBUG;
+
+ // Used to prevent trying to read the id of the downloaded file before it is written
+ static final Object sSharedIdProtector = new Object();
+
+ // Value used to mean this is not a real DownloadManager downloaded file id
+ // DownloadManager uses as an ID numbers returned out of an AUTOINCREMENT column
+ // in SQLite, so it should never return anything < 0.
+ public static final int NOT_AN_ID = -1;
+ public static final int MAXIMUM_SUPPORTED_FORMAT_VERSION = 2;
+
+ // Arbitrary. Probably good if it's a power of 2, and a couple thousand bytes long.
+ private static final int FILE_COPY_BUFFER_SIZE = 8192;
+
+ // Table fixed values for metadata / downloads
+ final static String METADATA_NAME = "metadata";
+ final static int METADATA_TYPE = 0;
+ final static int WORDLIST_TYPE = 1;
+
+ // Suffix for generated dictionary files
+ private static final String DICT_FILE_SUFFIX = ".dict";
+ // Name of the category for the main dictionary
+ public static final String MAIN_DICTIONARY_CATEGORY = "main";
+
+ /**
+ * The action of the intent for publishing that new dictionary data is available.
+ */
+ // TODO: make this different across different packages. A suggested course of action is
+ // to use the package name inside this string.
+ public static final String NEW_DICTIONARY_INTENT_ACTION =
+ "com.android.inputmethod.dictionarypack.newdict";
+
+ // The id for the "dictionary available" notification.
+ static final int DICT_AVAILABLE_NOTIFICATION_ID = 1;
+
+ /**
+ * An interface for UIs or services that want to know when something happened.
+ *
+ * This is chiefly used by the dictionary manager UI.
+ */
+ public interface UpdateEventListener {
+ public void downloadedMetadata(boolean succeeded);
+ public void wordListDownloadFinished(String wordListId, boolean succeeded);
+ public void updateCycleCompleted();
+ }
+
+ /**
+ * The list of currently registered listeners.
+ */
+ private static List sUpdateEventListeners
+ = Collections.synchronizedList(new LinkedList());
+
+ /**
+ * Register a new listener to be notified of updates.
+ *
+ * Don't forget to call unregisterUpdateEventListener when done with it, or
+ * it will leak the register.
+ */
+ public static void registerUpdateEventListener(final UpdateEventListener listener) {
+ sUpdateEventListeners.add(listener);
+ }
+
+ /**
+ * Unregister a previously registered listener.
+ */
+ public static void unregisterUpdateEventListener(final UpdateEventListener listener) {
+ sUpdateEventListeners.remove(listener);
+ }
+
+ private static final String DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY = "downloadOverMetered";
+
+ /**
+ * Write the DownloadManager ID of the currently downloading metadata to permanent storage.
+ *
+ * @param context to open shared prefs
+ * @param uri the uri of the metadata
+ * @param downloadId the id returned by DownloadManager
+ */
+ private static void writeMetadataDownloadId(final Context context, final String uri,
+ final long downloadId) {
+ MetadataDbHelper.registerMetadataDownloadId(context, uri, downloadId);
+ }
+
+ public static final int DOWNLOAD_OVER_METERED_SETTING_UNKNOWN = 0;
+ public static final int DOWNLOAD_OVER_METERED_ALLOWED = 1;
+ public static final int DOWNLOAD_OVER_METERED_DISALLOWED = 2;
+
+ /**
+ * Sets the setting that tells us whether we may download over a metered connection.
+ */
+ public static void setDownloadOverMeteredSetting(final Context context,
+ final boolean shouldDownloadOverMetered) {
+ final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putInt(DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY, shouldDownloadOverMetered
+ ? DOWNLOAD_OVER_METERED_ALLOWED : DOWNLOAD_OVER_METERED_DISALLOWED);
+ editor.apply();
+ }
+
+ /**
+ * Gets the setting that tells us whether we may download over a metered connection.
+ *
+ * This returns one of the constants above.
+ */
+ public static int getDownloadOverMeteredSetting(final Context context) {
+ final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context);
+ final int setting = prefs.getInt(DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY,
+ DOWNLOAD_OVER_METERED_SETTING_UNKNOWN);
+ return setting;
+ }
+
+ /**
+ * Download latest metadata from the server through DownloadManager for all known clients
+ * @param context The context for retrieving resources
+ * @param updateNow Whether we should update NOW, or respect bandwidth policies
+ */
+ public static void update(final Context context, final boolean updateNow) {
+ // TODO: loop through all clients instead of only doing the default one.
+ final TreeSet uris = new TreeSet();
+ final Cursor cursor = MetadataDbHelper.queryClientIds(context);
+ if (null == cursor) return;
+ try {
+ if (!cursor.moveToFirst()) return;
+ do {
+ final String clientId = cursor.getString(0);
+ if (TextUtils.isEmpty(clientId)) continue; // This probably can't happen
+ final String metadataUri =
+ MetadataDbHelper.getMetadataUriAsString(context, clientId);
+ PrivateLog.log("Update for clientId " + Utils.s(clientId), context);
+ Utils.l("Update for clientId", clientId, " which uses URI ", metadataUri);
+ uris.add(metadataUri);
+ } while (cursor.moveToNext());
+ } finally {
+ cursor.close();
+ }
+ for (final String metadataUri : uris) {
+ if (!TextUtils.isEmpty(metadataUri)) {
+ // If the metadata URI is empty, that means we should never update it at all.
+ // It should not be possible to come here with a null metadata URI, because
+ // it should have been rejected at the time of client registration; if there
+ // is a bug and it happens anyway, doing nothing is the right thing to do.
+ // For more information, {@see DictionaryProvider#insert(Uri, ContentValues)}.
+ updateClientsWithMetadataUri(context, updateNow, metadataUri);
+ }
+ }
+ }
+
+ /**
+ * Download latest metadata from the server through DownloadManager for all relevant clients
+ *
+ * @param context The context for retrieving resources
+ * @param updateNow Whether we should update NOW, or respect bandwidth policies
+ * @param metadataUri The client to update
+ */
+ private static void updateClientsWithMetadataUri(final Context context,
+ final boolean updateNow, final String metadataUri) {
+ PrivateLog.log("Update for metadata URI " + Utils.s(metadataUri), context);
+ final Request metadataRequest = new Request(Uri.parse(metadataUri));
+ Utils.l("Request =", metadataRequest);
+
+ final Resources res = context.getResources();
+ // By default, download over roaming is allowed and all network types are allowed too.
+ if (!updateNow) {
+ final boolean allowedOverMetered = res.getBoolean(R.bool.allow_over_metered);
+ // If we don't have to update NOW, then only do it over non-metered connections.
+ if (DownloadManagerCompatUtils.hasSetAllowedOverMetered()) {
+ DownloadManagerCompatUtils.setAllowedOverMetered(metadataRequest,
+ allowedOverMetered);
+ } else if (!allowedOverMetered) {
+ metadataRequest.setAllowedNetworkTypes(Request.NETWORK_WIFI);
+ }
+ metadataRequest.setAllowedOverRoaming(res.getBoolean(R.bool.allow_over_roaming));
+ }
+ final boolean notificationVisible = updateNow
+ ? res.getBoolean(R.bool.display_notification_for_user_requested_update)
+ : res.getBoolean(R.bool.display_notification_for_auto_update);
+
+ metadataRequest.setTitle(res.getString(R.string.download_description));
+ metadataRequest.setNotificationVisibility(notificationVisible
+ ? Request.VISIBILITY_VISIBLE : Request.VISIBILITY_HIDDEN);
+ metadataRequest.setVisibleInDownloadsUi(
+ res.getBoolean(R.bool.metadata_downloads_visible_in_download_UI));
+
+ final DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (null == manager) {
+ // Download manager is not installed or disabled.
+ // TODO: fall back to self-managed download?
+ return;
+ }
+ cancelUpdateWithDownloadManager(context, metadataUri, manager);
+ final long downloadId;
+ synchronized (sSharedIdProtector) {
+ downloadId = manager.enqueue(metadataRequest);
+ Utils.l("Metadata download requested with id", downloadId);
+ // If there is already a download in progress, it's been there for a while and
+ // there is probably something wrong with download manager. It's best to just
+ // overwrite the id and request it again. If the old one happens to finish
+ // anyway, we don't know about its ID any more, so the downloadFinished
+ // method will ignore it.
+ writeMetadataDownloadId(context, metadataUri, downloadId);
+ }
+ PrivateLog.log("Requested download with id " + downloadId, context);
+ }
+
+ /**
+ * Cancels a pending update, if there is one.
+ *
+ * If none, this is a no-op.
+ *
+ * @param context the context to open the database on
+ * @param clientId the id of the client
+ * @param manager an instance of DownloadManager
+ */
+ private static void cancelUpdateWithDownloadManager(final Context context,
+ final String clientId, final DownloadManager manager) {
+ synchronized (sSharedIdProtector) {
+ final long metadataDownloadId =
+ MetadataDbHelper.getMetadataDownloadIdForClient(context, clientId);
+ if (NOT_AN_ID == metadataDownloadId) return;
+ manager.remove(metadataDownloadId);
+ writeMetadataDownloadId(context,
+ MetadataDbHelper.getMetadataUriAsString(context, clientId), NOT_AN_ID);
+ }
+ // Consider a cancellation as a failure. As such, inform listeners that the download
+ // has failed.
+ for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) {
+ listener.downloadedMetadata(false);
+ }
+ }
+
+ /**
+ * Cancels a pending update, if there is one.
+ *
+ * If there is none, this is a no-op. This is a helper method that gets the
+ * download manager service.
+ *
+ * @param context the context, to get an instance of DownloadManager
+ * @param clientId the ID of the client we want to cancel the update of
+ */
+ public static void cancelUpdate(final Context context, final String clientId) {
+ final DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (null != manager) cancelUpdateWithDownloadManager(context, clientId, manager);
+ }
+
+ /**
+ * Registers a download request and flags it as downloading in the metadata table.
+ *
+ * This is a helper method that exists to avoid race conditions where DownloadManager might
+ * finish downloading the file before the data is committed to the database.
+ * It registers the request with the DownloadManager service and also updates the metadata
+ * database directly within a synchronized section.
+ * This method has no intelligence about the data it commits to the database aside from the
+ * download request id, which is not known before submitting the request to the download
+ * manager. Hence, it only updates the relevant line.
+ *
+ * @param manager the download manager service to register the request with.
+ * @param request the request to register.
+ * @param db the metadata database.
+ * @param id the id of the word list.
+ * @param version the version of the word list.
+ * @return the download id returned by the download manager.
+ */
+ public static long registerDownloadRequest(final DownloadManager manager, final Request request,
+ final SQLiteDatabase db, final String id, final int version) {
+ Utils.l("RegisterDownloadRequest for word list id : ", id, ", version ", version);
+ final long downloadId;
+ synchronized (sSharedIdProtector) {
+ downloadId = manager.enqueue(request);
+ Utils.l("Download requested with id", downloadId);
+ MetadataDbHelper.markEntryAsDownloading(db, id, version, downloadId);
+ }
+ return downloadId;
+ }
+
+ /**
+ * Retrieve information about a specific download from DownloadManager.
+ */
+ private static CompletedDownloadInfo getCompletedDownloadInfo(final DownloadManager manager,
+ final long downloadId) {
+ final Query query = new Query().setFilterById(downloadId);
+ final Cursor cursor = manager.query(query);
+
+ if (null == cursor) {
+ return new CompletedDownloadInfo(null, downloadId, DownloadManager.STATUS_FAILED);
+ }
+ try {
+ final String uri;
+ final int status;
+ if (cursor.moveToNext()) {
+ final int columnStatus = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS);
+ final int columnError = cursor.getColumnIndex(DownloadManager.COLUMN_REASON);
+ final int columnUri = cursor.getColumnIndex(DownloadManager.COLUMN_URI);
+ final int error = cursor.getInt(columnError);
+ status = cursor.getInt(columnStatus);
+ uri = cursor.getString(columnUri);
+ if (DownloadManager.STATUS_SUCCESSFUL != status) {
+ Log.e(TAG, "Permanent failure of download " + downloadId
+ + " with error code: " + error);
+ }
+ } else {
+ uri = null;
+ status = DownloadManager.STATUS_FAILED;
+ }
+ return new CompletedDownloadInfo(uri, downloadId, status);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private static ArrayList getDownloadRecordsForCompletedDownloadInfo(
+ final Context context, final CompletedDownloadInfo downloadInfo) {
+ // Get and check the ID of the file we are waiting for, compare them to downloaded ones
+ synchronized(sSharedIdProtector) {
+ final ArrayList downloadRecords =
+ MetadataDbHelper.getDownloadRecordsForDownloadId(context,
+ downloadInfo.mDownloadId);
+ // If any of these is metadata, we should update the DB
+ boolean hasMetadata = false;
+ for (DownloadRecord record : downloadRecords) {
+ if (null == record.mAttributes) {
+ hasMetadata = true;
+ break;
+ }
+ }
+ if (hasMetadata) {
+ writeMetadataDownloadId(context, downloadInfo.mUri, NOT_AN_ID);
+ MetadataDbHelper.saveLastUpdateTimeOfUri(context, downloadInfo.mUri);
+ }
+ return downloadRecords;
+ }
+ }
+
+ /**
+ * Take appropriate action after a download finished, in success or in error.
+ *
+ * This is called by the system upon broadcast from the DownloadManager that a file
+ * has been downloaded successfully.
+ * After a simple check that this is actually the file we are waiting for, this
+ * method basically coordinates the parsing and comparison of metadata, and fires
+ * the computation of the list of actions that should be taken then executes them.
+ *
+ * @param context The context for this action.
+ * @param intent The intent from the DownloadManager containing details about the download.
+ */
+ /* package */ static void downloadFinished(final Context context, final Intent intent) {
+ // Get and check the ID of the file that was downloaded
+ final long fileId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, NOT_AN_ID);
+ PrivateLog.log("Download finished with id " + fileId, context);
+ Utils.l("DownloadFinished with id", fileId);
+ if (NOT_AN_ID == fileId) return; // Spurious wake-up: ignore
+
+ final DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ final CompletedDownloadInfo downloadInfo = getCompletedDownloadInfo(manager, fileId);
+
+ final ArrayList recordList =
+ getDownloadRecordsForCompletedDownloadInfo(context, downloadInfo);
+ if (null == recordList) return; // It was someone else's download.
+ Utils.l("Received result for download ", fileId);
+
+ // TODO: handle gracefully a null pointer here. This is practically impossible because
+ // we come here only when DownloadManager explicitly called us when it ended a
+ // download, so we are pretty sure it's alive. It's theoretically possible that it's
+ // disabled right inbetween the firing of the intent and the control reaching here.
+
+ for (final DownloadRecord record : recordList) {
+ // downloadSuccessful is not final because we may still have exceptions from now on
+ boolean downloadSuccessful = false;
+ try {
+ if (downloadInfo.wasSuccessful()) {
+ downloadSuccessful = handleDownloadedFile(context, record, manager, fileId);
+ }
+ } finally {
+ if (record.isMetadata()) {
+ publishUpdateMetadataCompleted(context, downloadSuccessful);
+ } else {
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, record.mClientId);
+ publishUpdateWordListCompleted(context, downloadSuccessful, fileId,
+ db, record.mAttributes, record.mClientId);
+ }
+ }
+ }
+ // Now that we're done using it, we can remove this download from DLManager
+ manager.remove(fileId);
+ }
+
+ private static void publishUpdateMetadataCompleted(final Context context,
+ final boolean downloadSuccessful) {
+ // We need to warn all listeners of what happened. But some listeners may want to
+ // remove themselves or re-register something in response. Hence we should take a
+ // snapshot of the listener list and warn them all. This also prevents any
+ // concurrent modification problem of the static list.
+ for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) {
+ listener.downloadedMetadata(downloadSuccessful);
+ }
+ publishUpdateCycleCompletedEvent(context);
+ }
+
+ private static void publishUpdateWordListCompleted(final Context context,
+ final boolean downloadSuccessful, final long fileId,
+ final SQLiteDatabase db, final ContentValues downloadedFileRecord,
+ final String clientId) {
+ synchronized(sSharedIdProtector) {
+ if (downloadSuccessful) {
+ final ActionBatch actions = new ActionBatch();
+ actions.add(new ActionBatch.InstallAfterDownloadAction(clientId,
+ downloadedFileRecord));
+ actions.execute(context, new LogProblemReporter(TAG));
+ } else {
+ MetadataDbHelper.deleteDownloadingEntry(db, fileId);
+ }
+ }
+ // See comment above about #linkedCopyOfLists
+ for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) {
+ listener.wordListDownloadFinished(downloadedFileRecord.getAsString(
+ MetadataDbHelper.WORDLISTID_COLUMN), downloadSuccessful);
+ }
+ publishUpdateCycleCompletedEvent(context);
+ }
+
+ private static void publishUpdateCycleCompletedEvent(final Context context) {
+ // Even if this is not successful, we have to publish the new state.
+ PrivateLog.log("Publishing update cycle completed event", context);
+ Utils.l("Publishing update cycle completed event");
+ for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) {
+ listener.updateCycleCompleted();
+ }
+ signalNewDictionaryState(context);
+ }
+
+ private static boolean handleDownloadedFile(final Context context,
+ final DownloadRecord downloadRecord, final DownloadManager manager,
+ final long fileId) {
+ try {
+ // {@link handleWordList(Context,InputStream,ContentValues)}.
+ // Handle the downloaded file according to its type
+ if (downloadRecord.isMetadata()) {
+ Utils.l("Data D/L'd is metadata for", downloadRecord.mClientId);
+ // #handleMetadata() closes its InputStream argument
+ handleMetadata(context, new ParcelFileDescriptor.AutoCloseInputStream(
+ manager.openDownloadedFile(fileId)), downloadRecord.mClientId);
+ } else {
+ Utils.l("Data D/L'd is a word list");
+ final int wordListStatus = downloadRecord.mAttributes.getAsInteger(
+ MetadataDbHelper.STATUS_COLUMN);
+ if (MetadataDbHelper.STATUS_DOWNLOADING == wordListStatus) {
+ // #handleWordList() closes its InputStream argument
+ handleWordList(context, new ParcelFileDescriptor.AutoCloseInputStream(
+ manager.openDownloadedFile(fileId)), downloadRecord);
+ } else {
+ Log.e(TAG, "Spurious download ended. Maybe a cancelled download?");
+ }
+ }
+ return true;
+ } catch (FileNotFoundException e) {
+ Log.e(TAG, "A file was downloaded but it can't be opened", e);
+ } catch (IOException e) {
+ // Can't read the file... disk damage?
+ Log.e(TAG, "Can't read a file", e);
+ // TODO: Check with UX how we should warn the user.
+ } catch (IllegalStateException e) {
+ // The format of the downloaded file is incorrect. We should maybe report upstream?
+ Log.e(TAG, "Incorrect data received", e);
+ } catch (BadFormatException e) {
+ // The format of the downloaded file is incorrect. We should maybe report upstream?
+ Log.e(TAG, "Incorrect data received", e);
+ }
+ return false;
+ }
+
+ /**
+ * Returns a copy of the specified list, with all elements copied.
+ *
+ * This returns a linked list.
+ */
+ private static List linkedCopyOfList(final List src) {
+ // Instantiation of a parameterized type is not possible in Java, so it's not possible to
+ // return the same type of list that was passed - probably the same reason why Collections
+ // does not do it. So we need to decide statically which concrete type to return.
+ return new LinkedList(src);
+ }
+
+ /**
+ * Warn Android Keyboard that the state of dictionaries changed and it should refresh its data.
+ */
+ private static void signalNewDictionaryState(final Context context) {
+ final Intent newDictBroadcast = new Intent(NEW_DICTIONARY_INTENT_ACTION);
+ context.sendBroadcast(newDictBroadcast);
+ }
+
+ /**
+ * Parse metadata and take appropriate action (that is, upgrade dictionaries).
+ * @param context the context to read settings.
+ * @param stream an input stream pointing to the downloaded data. May not be null.
+ * Will be closed upon finishing.
+ * @param clientId the ID of the client to update
+ * @throws BadFormatException if the metadata is not in a known format.
+ * @throws IOException if the downloaded file can't be read from the disk
+ */
+ private static void handleMetadata(final Context context, final InputStream stream,
+ final String clientId) throws IOException, BadFormatException {
+ Utils.l("Entering handleMetadata");
+ final List newMetadata;
+ final InputStreamReader reader = new InputStreamReader(stream);
+ try {
+ // According to the doc InputStreamReader buffers, so no need to add a buffering layer
+ newMetadata = MetadataHandler.readMetadata(reader);
+ } finally {
+ reader.close();
+ }
+
+ Utils.l("Downloaded metadata :", newMetadata);
+ PrivateLog.log("Downloaded metadata\n" + newMetadata, context);
+
+ final ActionBatch actions = computeUpgradeTo(context, clientId, newMetadata);
+ // TODO: Check with UX how we should report to the user
+ // TODO: add an action to close the database
+ actions.execute(context, new LogProblemReporter(TAG));
+ }
+
+ /**
+ * Handle a word list: put it in its right place, and update the passed content values.
+ * @param context the context for opening files.
+ * @param inputStream an input stream pointing to the downloaded data. May not be null.
+ * Will be closed upon finishing.
+ * @param downloadRecord the content values to fill the file name in.
+ * @throws IOException if files can't be read or written.
+ * @throws BadFormatException if the md5 checksum doesn't match the metadata.
+ */
+ private static void handleWordList(final Context context,
+ final InputStream inputStream, final DownloadRecord downloadRecord)
+ throws IOException, BadFormatException {
+
+ // DownloadManager does not have the ability to put the file directly where we want
+ // it, so we had it download to a temporary place. Now we move it. It will be deleted
+ // automatically by DownloadManager.
+ Utils.l("Downloaded a new word list :", downloadRecord.mAttributes.getAsString(
+ MetadataDbHelper.DESCRIPTION_COLUMN), "for", downloadRecord.mClientId);
+ PrivateLog.log("Downloaded a new word list with description : "
+ + downloadRecord.mAttributes.getAsString(MetadataDbHelper.DESCRIPTION_COLUMN)
+ + " for " + downloadRecord.mClientId, context);
+
+ final String locale =
+ downloadRecord.mAttributes.getAsString(MetadataDbHelper.LOCALE_COLUMN);
+ final String destinationFile = getTempFileName(context, locale);
+ downloadRecord.mAttributes.put(MetadataDbHelper.LOCAL_FILENAME_COLUMN, destinationFile);
+
+ FileOutputStream outputStream = null;
+ try {
+ outputStream = context.openFileOutput(destinationFile, Context.MODE_PRIVATE);
+ copyFile(inputStream, outputStream);
+ } finally {
+ inputStream.close();
+ if (outputStream != null) {
+ outputStream.close();
+ }
+ }
+
+ // TODO: Consolidate this MD5 calculation with file copying above.
+ // We need to reopen the file because the inputstream bytes have been consumed, and there
+ // is nothing in InputStream to reopen or rewind the stream
+ FileInputStream copiedFile = null;
+ final String md5sum;
+ try {
+ copiedFile = context.openFileInput(destinationFile);
+ md5sum = MD5Calculator.checksum(copiedFile);
+ } finally {
+ if (copiedFile != null) {
+ copiedFile.close();
+ }
+ }
+ if (TextUtils.isEmpty(md5sum)) {
+ return; // We can't compute the checksum anyway, so return and hope for the best
+ }
+ if (!md5sum.equals(downloadRecord.mAttributes.getAsString(
+ MetadataDbHelper.CHECKSUM_COLUMN))) {
+ context.deleteFile(destinationFile);
+ throw new BadFormatException("MD5 checksum check failed : \"" + md5sum + "\" <> \""
+ + downloadRecord.mAttributes.getAsString(MetadataDbHelper.CHECKSUM_COLUMN)
+ + "\"");
+ }
+ }
+
+ /**
+ * Copies in to out using FileChannels.
+ *
+ * This tries to use channels for fast copying. If it doesn't work, fall back to
+ * copyFileFallBack below.
+ *
+ * @param in the stream to copy from.
+ * @param out the stream to copy to.
+ * @throws IOException if both the normal and fallback methods raise exceptions.
+ */
+ private static void copyFile(final InputStream in, final OutputStream out)
+ throws IOException {
+ Utils.l("Copying files");
+ if (!(in instanceof FileInputStream) || !(out instanceof FileOutputStream)) {
+ Utils.l("Not the right types");
+ copyFileFallback(in, out);
+ } else {
+ try {
+ final FileChannel sourceChannel = ((FileInputStream) in).getChannel();
+ final FileChannel destinationChannel = ((FileOutputStream) out).getChannel();
+ sourceChannel.transferTo(0, Integer.MAX_VALUE, destinationChannel);
+ } catch (IOException e) {
+ // Can't work with channels, or something went wrong. Copy by hand.
+ Utils.l("Won't work");
+ copyFileFallback(in, out);
+ }
+ }
+ }
+
+ /**
+ * Copies in to out with read/write methods, not FileChannels.
+ *
+ * @param in the stream to copy from.
+ * @param out the stream to copy to.
+ * @throws IOException if a read or a write fails.
+ */
+ private static void copyFileFallback(final InputStream in, final OutputStream out)
+ throws IOException {
+ Utils.l("Falling back to slow copy");
+ final byte[] buffer = new byte[FILE_COPY_BUFFER_SIZE];
+ for (int readBytes = in.read(buffer); readBytes >= 0; readBytes = in.read(buffer))
+ out.write(buffer, 0, readBytes);
+ }
+
+ /**
+ * Creates and returns a new file to store a dictionary
+ * @param context the context to use to open the file.
+ * @param locale the locale for this dictionary, to make the file name more readable.
+ * @return the file name, or throw an exception.
+ * @throws IOException if the file cannot be created.
+ */
+ private static String getTempFileName(final Context context, final String locale)
+ throws IOException {
+ Utils.l("Entering openTempFileOutput");
+ final File dir = context.getFilesDir();
+ final File f = File.createTempFile(locale + "___", DICT_FILE_SUFFIX, dir);
+ Utils.l("File name is", f.getName());
+ return f.getName();
+ }
+
+ /**
+ * Compare metadata (collections of word lists).
+ *
+ * This method takes whole metadata sets directly and compares them, matching the wordlists in
+ * each of them on the id. It creates an ActionBatch object that can be .execute()'d to perform
+ * the actual upgrade from `from' to `to'.
+ *
+ * @param context the context to open databases on.
+ * @param clientId the id of the client.
+ * @param from the dictionary descriptor (as a list of wordlists) to upgrade from.
+ * @param to the dictionary descriptor (as a list of wordlists) to upgrade to.
+ * @return an ordered list of runnables to be called to upgrade.
+ */
+ private static ActionBatch compareMetadataForUpgrade(final Context context,
+ final String clientId, List from, List to) {
+ final ActionBatch actions = new ActionBatch();
+ // Upgrade existing word lists
+ Utils.l("Comparing dictionaries");
+ final Set wordListIds = new TreeSet();
+ // TODO: Can these be null?
+ if (null == from) from = new ArrayList();
+ if (null == to) to = new ArrayList();
+ for (WordListMetadata wlData : from) wordListIds.add(wlData.mId);
+ for (WordListMetadata wlData : to) wordListIds.add(wlData.mId);
+ for (String id : wordListIds) {
+ final WordListMetadata currentInfo = MetadataHandler.findWordListById(from, id);
+ final WordListMetadata metadataInfo = MetadataHandler.findWordListById(to, id);
+ // TODO: Remove the following unnecessary check, since we are now doing the filtering
+ // inside findWordListById.
+ final WordListMetadata newInfo = null == metadataInfo
+ || metadataInfo.mFormatVersion > MAXIMUM_SUPPORTED_FORMAT_VERSION
+ ? null : metadataInfo;
+ Utils.l("Considering updating ", id, "currentInfo =", currentInfo);
+
+ if (null == currentInfo && null == newInfo) {
+ // This may happen if a new word list appeared that we can't handle.
+ if (null == metadataInfo) {
+ // What happened? Bug in Set<>?
+ Log.e(TAG, "Got an id for a wordlist that is neither in from nor in to");
+ } else {
+ // We may come here if there is a new word list that we can't handle.
+ Log.i(TAG, "Can't handle word list with id '" + id + "' because it has format"
+ + " version " + metadataInfo.mFormatVersion + " and the maximum version"
+ + "we can handle is " + MAXIMUM_SUPPORTED_FORMAT_VERSION);
+ }
+ continue;
+ } else if (null == currentInfo) {
+ // This is the case where a new list that we did not know of popped on the server.
+ // Make it available.
+ actions.add(new ActionBatch.MakeAvailableAction(clientId, newInfo));
+ } else if (null == newInfo) {
+ // This is the case where an old list we had is not in the server data any more.
+ // Pass false to ForgetAction: this may be installed and we still want to apply
+ // a forget-like action (remove the URL) if it is, so we want to turn off the
+ // status == AVAILABLE check. If it's DELETING, this is the right thing to do,
+ // as we want to leave the record as long as Android Keyboard has not deleted it ;
+ // the record will be removed when the file is actually deleted.
+ actions.add(new ActionBatch.ForgetAction(clientId, currentInfo, false));
+ } else {
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
+ if (newInfo.mVersion == currentInfo.mVersion) {
+ // If it's the same id/version, we update the DB with the new values.
+ // It doesn't matter too much if they didn't change.
+ actions.add(new ActionBatch.UpdateDataAction(clientId, newInfo));
+ } else if (newInfo.mVersion > currentInfo.mVersion) {
+ // If it's a new version, it's a different entry in the database. Make it
+ // available, and if it's installed, also start the download.
+ final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
+ currentInfo.mId, currentInfo.mVersion);
+ final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ actions.add(new ActionBatch.MakeAvailableAction(clientId, newInfo));
+ if (status == MetadataDbHelper.STATUS_INSTALLED
+ || status == MetadataDbHelper.STATUS_DISABLED) {
+ actions.add(new ActionBatch.StartDownloadAction(clientId, newInfo, false));
+ } else {
+ // Pass true to ForgetAction: this is indeed an update to a non-installed
+ // word list, so activate status == AVAILABLE check
+ // In case the status is DELETING, this is the right thing to do. It will
+ // leave the entry as DELETING and remove its URL so that Android Keyboard
+ // can delete it the next time it starts up.
+ actions.add(new ActionBatch.ForgetAction(clientId, currentInfo, true));
+ }
+ } else if (DEBUG) {
+ Log.i(TAG, "Not updating word list " + id
+ + " : current list timestamp is " + currentInfo.mLastUpdate
+ + " ; new list timestamp is " + newInfo.mLastUpdate);
+ }
+ }
+ }
+ return actions;
+ }
+
+ /**
+ * Computes an upgrade from the current state of the dictionaries to some desired state.
+ * @param context the context for reading settings and files.
+ * @param clientId the id of the client.
+ * @param newMetadata the state we want to upgrade to.
+ * @return the upgrade from the current state to the desired state, ready to be executed.
+ */
+ public static ActionBatch computeUpgradeTo(final Context context, final String clientId,
+ final List newMetadata) {
+ final List currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ return compareMetadataForUpgrade(context, clientId, currentMetadata, newMetadata);
+ }
+
+ /**
+ * Shows the notification that informs the user a dictionary is available.
+ *
+ * When this notification is clicked, the dialog for downloading the dictionary
+ * over a metered connection is shown.
+ */
+ private static void showDictionaryAvailableNotification(final Context context,
+ final String clientId, final ContentValues installCandidate) {
+ final String localeString = installCandidate.getAsString(MetadataDbHelper.LOCALE_COLUMN);
+ final Intent intent = new Intent();
+ intent.setClass(context, DownloadOverMeteredDialog.class);
+ intent.putExtra(DownloadOverMeteredDialog.CLIENT_ID_KEY, clientId);
+ intent.putExtra(DownloadOverMeteredDialog.WORDLIST_TO_DOWNLOAD_KEY,
+ installCandidate.getAsString(MetadataDbHelper.WORDLISTID_COLUMN));
+ intent.putExtra(DownloadOverMeteredDialog.SIZE_KEY,
+ installCandidate.getAsInteger(MetadataDbHelper.FILESIZE_COLUMN));
+ intent.putExtra(DownloadOverMeteredDialog.LOCALE_KEY, localeString);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+ final PendingIntent notificationIntent = PendingIntent.getActivity(context,
+ 0 /* requestCode */, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT);
+ final NotificationManager notificationManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ // None of those are expected to happen, but just in case...
+ if (null == notificationIntent || null == notificationManager) return;
+
+ final Locale locale = LocaleUtils.constructLocaleFromString(localeString);
+ final String language = (null == locale ? "" : locale.getDisplayLanguage());
+ final String titleFormat = context.getString(R.string.dict_available_notification_title);
+ final String notificationTitle = String.format(titleFormat, language);
+ final Notification notification = new Notification.Builder(context)
+ .setAutoCancel(true)
+ .setContentIntent(notificationIntent)
+ .setContentTitle(notificationTitle)
+ .setContentText(context.getString(R.string.dict_available_notification_description))
+ .setTicker(notificationTitle)
+ .setOngoing(false)
+ .setOnlyAlertOnce(true)
+ .setSmallIcon(R.drawable.ic_notify_dictionary)
+ .getNotification();
+ notificationManager.notify(DICT_AVAILABLE_NOTIFICATION_ID, notification);
+ }
+
+ /**
+ * Installs a word list if it has never been requested.
+ *
+ * This is called when a word list is requested, and is available but not installed. It checks
+ * the conditions for auto-installation: if the dictionary is a main dictionary for this
+ * language, and it has never been opted out through the dictionary interface, then we start
+ * installing it. For the user who enables a language and uses it for the first time, the
+ * dictionary should magically start being used a short time after they start typing.
+ * The mayPrompt argument indicates whether we should prompt the user for a decision to
+ * download or not, in case we decide we are in the case where we should download - this
+ * roughly happens when the current connectivity is 3G. See
+ * DictionaryProvider#getDictionaryWordListsForContentUri for details.
+ */
+ // As opposed to many other methods, this method does not need the version of the word
+ // list because it may only install the latest version we know about for this specific
+ // word list ID / client ID combination.
+ public static void installIfNeverRequested(final Context context, final String clientId,
+ final String wordlistId, final boolean mayPrompt) {
+ final String[] idArray = wordlistId.split(DictionaryProvider.ID_CATEGORY_SEPARATOR);
+ // If we have a new-format dictionary id (category:manual_id), then use the
+ // specified category. Otherwise, it is a main dictionary, so force the
+ // MAIN category upon it.
+ final String category = 2 == idArray.length ? idArray[0] : MAIN_DICTIONARY_CATEGORY;
+ if (!MAIN_DICTIONARY_CATEGORY.equals(category)) {
+ // Not a main dictionary. We only auto-install main dictionaries, so we can return now.
+ return;
+ }
+ if (CommonPreferences.getCommonPreferences(context).contains(wordlistId)) {
+ // If some kind of settings has been done in the past for this specific id, then
+ // this is not a candidate for auto-install. Because it already is either true,
+ // in which case it may be installed or downloading or whatever, and we don't
+ // need to care about it because it's already handled or being handled, or it's false
+ // in which case it means the user explicitely turned it off and don't want to have
+ // it installed. So we quit right away.
+ return;
+ }
+
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
+ final ContentValues installCandidate =
+ MetadataDbHelper.getContentValuesOfLatestAvailableWordlistById(db, wordlistId);
+ if (MetadataDbHelper.STATUS_AVAILABLE
+ != installCandidate.getAsInteger(MetadataDbHelper.STATUS_COLUMN)) {
+ // If it's not "AVAILABLE", we want to stop now. Because candidates for auto-install
+ // are lists that we know are available, but we also know have never been installed.
+ // It does obviously not concern already installed lists, or downloading lists,
+ // or those that have been disabled, flagged as deleting... So anything else than
+ // AVAILABLE means we don't auto-install.
+ return;
+ }
+
+ if (mayPrompt
+ && DOWNLOAD_OVER_METERED_SETTING_UNKNOWN
+ == getDownloadOverMeteredSetting(context)) {
+ final ConnectivityManager cm =
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (ConnectivityManagerCompatUtils.isActiveNetworkMetered(cm)) {
+ showDictionaryAvailableNotification(context, clientId, installCandidate);
+ return;
+ }
+ }
+
+ // We decided against prompting the user for a decision. This may be because we were
+ // explicitly asked not to, or because we are currently on wi-fi anyway, or because we
+ // already know the answer to the question. We'll enqueue a request ; StartDownloadAction
+ // knows to use the correct type of network according to the current settings.
+
+ // Also note that once it's auto-installed, a word list will be marked as INSTALLED. It will
+ // thus receive automatic updates if there are any, which is what we want. If the user does
+ // not want this word list, they will have to go to the settings and change them, which will
+ // change the shared preferences. So there is no way for a word list that has been
+ // auto-installed once to get auto-installed again, and that's what we want.
+ final ActionBatch actions = new ActionBatch();
+ actions.add(new ActionBatch.StartDownloadAction(clientId,
+ WordListMetadata.createFromContentValues(installCandidate), false));
+ final String localeString = installCandidate.getAsString(MetadataDbHelper.LOCALE_COLUMN);
+ // We are in a content provider: we can't do any UI at all. We have to defer the displaying
+ // itself to the service. Also, we only display this when the user does not have a
+ // dictionary for this language already: we know that from the mayPrompt argument.
+ if (mayPrompt) {
+ final Intent intent = new Intent();
+ intent.setClass(context, DictionaryService.class);
+ intent.setAction(DictionaryService.SHOW_DOWNLOAD_TOAST_INTENT_ACTION);
+ intent.putExtra(DictionaryService.LOCALE_INTENT_ARGUMENT, localeString);
+ context.startService(intent);
+ }
+ actions.execute(context, new LogProblemReporter(TAG));
+ }
+
+ /**
+ * Marks the word list with the passed id as used.
+ *
+ * This will download/install the list as required. The action will see that the destination
+ * word list is a valid list, and take appropriate action - in this case, mark it as used.
+ * @see ActionBatch.Action#execute
+ *
+ * @param context the context for using action batches.
+ * @param clientId the id of the client.
+ * @param wordlistId the id of the word list to mark as installed.
+ * @param version the version of the word list to mark as installed.
+ * @param status the current status of the word list.
+ * @param allowDownloadOnMeteredData whether to download even on metered data connection
+ */
+ // The version argument is not used yet, because we don't need it to retrieve the information
+ // we need. However, the pair (id, version) being the primary key to a word list in the database
+ // it feels better for consistency to pass it, and some methods retrieving information about a
+ // word list need it so we may need it in the future.
+ public static void markAsUsed(final Context context, final String clientId,
+ final String wordlistId, final int version,
+ final int status, final boolean allowDownloadOnMeteredData) {
+ final List currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ WordListMetadata wordList = MetadataHandler.findWordListById(currentMetadata, wordlistId);
+ if (null == wordList) return;
+ final ActionBatch actions = new ActionBatch();
+ if (MetadataDbHelper.STATUS_DISABLED == status
+ || MetadataDbHelper.STATUS_DELETING == status) {
+ actions.add(new ActionBatch.EnableAction(clientId, wordList));
+ } else if (MetadataDbHelper.STATUS_AVAILABLE == status) {
+ actions.add(new ActionBatch.StartDownloadAction(clientId, wordList,
+ allowDownloadOnMeteredData));
+ } else {
+ Log.e(TAG, "Unexpected state of the word list for markAsUsed : " + status);
+ }
+ actions.execute(context, new LogProblemReporter(TAG));
+ signalNewDictionaryState(context);
+ }
+
+ /**
+ * Marks the word list with the passed id as unused.
+ *
+ * This leaves the file on the disk for ulterior use. The action will see that the destination
+ * word list is null, and take appropriate action - in this case, mark it as unused.
+ * @see ActionBatch.Action#execute
+ *
+ * @param context the context for using action batches.
+ * @param clientId the id of the client.
+ * @param wordlistId the id of the word list to mark as installed.
+ * @param version the version of the word list to mark as installed.
+ * @param status the current status of the word list.
+ */
+ // The version and status arguments are not used yet, but this method matches its interface to
+ // markAsUsed for consistency.
+ public static void markAsUnused(final Context context, final String clientId,
+ final String wordlistId, final int version, final int status) {
+ final List currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ final WordListMetadata wordList =
+ MetadataHandler.findWordListById(currentMetadata, wordlistId);
+ if (null == wordList) return;
+ final ActionBatch actions = new ActionBatch();
+ actions.add(new ActionBatch.DisableAction(clientId, wordList));
+ actions.execute(context, new LogProblemReporter(TAG));
+ signalNewDictionaryState(context);
+ }
+
+ /**
+ * Marks the word list with the passed id as deleting.
+ *
+ * This basically means that on the next chance there is (right away if Android Keyboard
+ * happens to be up, or the next time it gets up otherwise) the dictionary pack will
+ * supply an empty dictionary to it that will replace whatever dictionary is installed.
+ * This allows to release the space taken by a dictionary (except for the few bytes the
+ * empty dictionary takes up), and override a built-in default dictionary so that we
+ * can fake delete a built-in dictionary.
+ *
+ * @param context the context to open the database on.
+ * @param clientId the id of the client.
+ * @param wordlistId the id of the word list to mark as deleted.
+ * @param version the version of the word list to mark as deleted.
+ * @param status the current status of the word list.
+ */
+ public static void markAsDeleting(final Context context, final String clientId,
+ final String wordlistId, final int version, final int status) {
+ final List currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ final WordListMetadata wordList =
+ MetadataHandler.findWordListById(currentMetadata, wordlistId);
+ if (null == wordList) return;
+ final ActionBatch actions = new ActionBatch();
+ actions.add(new ActionBatch.DisableAction(clientId, wordList));
+ actions.add(new ActionBatch.StartDeleteAction(clientId, wordList));
+ actions.execute(context, new LogProblemReporter(TAG));
+ signalNewDictionaryState(context);
+ }
+
+ /**
+ * Marks the word list with the passed id as actually deleted.
+ *
+ * This reverts to available status or deletes the row as appropriate.
+ *
+ * @param context the context to open the database on.
+ * @param clientId the id of the client.
+ * @param wordlistId the id of the word list to mark as deleted.
+ * @param version the version of the word list to mark as deleted.
+ * @param status the current status of the word list.
+ */
+ public static void markAsDeleted(final Context context, final String clientId,
+ final String wordlistId, final int version, final int status) {
+ final List currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ final WordListMetadata wordList =
+ MetadataHandler.findWordListById(currentMetadata, wordlistId);
+ if (null == wordList) return;
+ final ActionBatch actions = new ActionBatch();
+ actions.add(new ActionBatch.FinishDeleteAction(clientId, wordList));
+ actions.execute(context, new LogProblemReporter(TAG));
+ signalNewDictionaryState(context);
+ }
+
+ /**
+ * Marks the word list with the passed id as broken.
+ *
+ * This effectively deletes the entry from the metadata. It doesn't prevent the same
+ * word list to be downloaded again at a later time if the same or a new version is
+ * available the next time we download the metadata.
+ *
+ * @param context the context to open the database on.
+ * @param clientId the id of the client.
+ * @param wordlistId the id of the word list to mark as broken.
+ * @param version the version of the word list to mark as deleted.
+ */
+ public static void markAsBroken(final Context context, final String clientId,
+ final String wordlistId, final int version) {
+ // TODO: do this on another thread to avoid blocking the UI.
+ MetadataDbHelper.deleteEntry(MetadataDbHelper.getDb(context, clientId),
+ wordlistId, version);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/Utils.java b/java/src/com/android/inputmethod/dictionarypack/Utils.java
new file mode 100644
index 000000000..c4a42dbbf
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/Utils.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.util.Log;
+
+/**
+ * A class for various utility methods, especially debugging.
+ */
+public final class Utils {
+ private final static String TAG = Utils.class.getSimpleName() + ":DEBUG --";
+ private final static boolean DEBUG = DictionaryProvider.DEBUG;
+
+ /**
+ * Calls .toString() on its non-null argument or returns "null"
+ * @param o the object to convert to a string
+ * @return the result of .toString() or null
+ */
+ public static String s(final Object o) {
+ return null == o ? "null" : o.toString();
+ }
+
+ /**
+ * Get the string representation of the current stack trace, for debugging purposes.
+ * @return a readable, carriage-return-separated string for the current stack trace.
+ */
+ public static String getStackTrace() {
+ final StringBuilder sb = new StringBuilder();
+ try {
+ throw new RuntimeException();
+ } catch (RuntimeException e) {
+ StackTraceElement[] frames = e.getStackTrace();
+ // Start at 1 because the first frame is here and we don't care about it
+ for (int j = 1; j < frames.length; ++j) {
+ sb.append(frames[j].toString() + "\n");
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Get the stack trace contained in an exception as a human-readable string.
+ * @param e the exception
+ * @return the human-readable stack trace
+ */
+ public static String getStackTrace(final Exception e) {
+ final StringBuilder sb = new StringBuilder();
+ final StackTraceElement[] frames = e.getStackTrace();
+ for (int j = 0; j < frames.length; ++j) {
+ sb.append(frames[j].toString() + "\n");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Helper log method to ease null-checks and adding spaces.
+ *
+ * This sends all arguments to the log, separated by spaces. Any null argument is converted
+ * to the "null" string. It uses a very visible tag and log level for debugging purposes.
+ *
+ * @param args the stuff to send to the log
+ */
+ public static void l(final Object... args) {
+ if (!DEBUG) return;
+ final StringBuilder sb = new StringBuilder();
+ for (final Object o : args) {
+ sb.append(s(o).toString());
+ sb.append(" ");
+ }
+ Log.e(TAG, sb.toString());
+ }
+
+ /**
+ * Helper log method to put stuff in red.
+ *
+ * This does the same as #l but prints in red
+ *
+ * @param args the stuff to send to the log
+ */
+ public static void r(final Object... args) {
+ if (!DEBUG) return;
+ final StringBuilder sb = new StringBuilder("\u001B[31m");
+ for (final Object o : args) {
+ sb.append(s(o).toString());
+ sb.append(" ");
+ }
+ sb.append("\u001B[0m");
+ Log.e(TAG, sb.toString());
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/WordListMetadata.java b/java/src/com/android/inputmethod/dictionarypack/WordListMetadata.java
new file mode 100644
index 000000000..69bff9597
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/WordListMetadata.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentValues;
+
+/**
+ * The metadata for a single word list.
+ *
+ * Instances of this class are always immutable.
+ */
+public class WordListMetadata {
+
+ public final String mId;
+ public final int mType; // Type, as of MetadataDbHelper#TYPE_*
+ public final String mDescription;
+ public final long mLastUpdate;
+ public final long mFileSize;
+ public final String mChecksum;
+ public final String mLocalFilename;
+ public final String mRemoteFilename;
+ public final int mVersion; // version of this word list
+ public final int mFlags; // Always 0 in this version, reserved for future use
+
+ // The locale is matched against the locale requested by the client. The matching algorithm
+ // is a standard locale matching with fallback; it is implemented in
+ // DictionaryProvider#getDictionaryFileForContentUri.
+ public final String mLocale;
+
+
+ // Version number of the format.
+ // This implementation of the DictionaryDataService knows how to handle format 1 only.
+ // This is only for forward compatibility, to be able to upgrade the format without
+ // breaking old implementations.
+ public final int mFormatVersion;
+
+ public WordListMetadata(final String id, final int type,
+ final String description, final long lastUpdate, final long fileSize,
+ final String checksum, final String localFilename, final String remoteFilename,
+ final int version, final int formatVersion, final int flags, final String locale) {
+ mId = id;
+ mType = type;
+ mDescription = description;
+ mLastUpdate = lastUpdate; // In milliseconds
+ mFileSize = fileSize;
+ mChecksum = checksum;
+ mLocalFilename = localFilename;
+ mRemoteFilename = remoteFilename;
+ mVersion = version;
+ mFormatVersion = formatVersion;
+ mFlags = flags;
+ mLocale = locale;
+ }
+
+ /**
+ * Create a WordListMetadata from the contents of a ContentValues.
+ *
+ * If this lacks any required field, IllegalArgumentException is thrown.
+ */
+ public static WordListMetadata createFromContentValues(final ContentValues values) {
+ final String id = values.getAsString(MetadataDbHelper.WORDLISTID_COLUMN);
+ final Integer type = values.getAsInteger(MetadataDbHelper.TYPE_COLUMN);
+ final String description = values.getAsString(MetadataDbHelper.DESCRIPTION_COLUMN);
+ final Long lastUpdate = values.getAsLong(MetadataDbHelper.DATE_COLUMN);
+ final Long fileSize = values.getAsLong(MetadataDbHelper.FILESIZE_COLUMN);
+ final String checksum = values.getAsString(MetadataDbHelper.CHECKSUM_COLUMN);
+ final String localFilename = values.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
+ final String remoteFilename = values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN);
+ final Integer version = values.getAsInteger(MetadataDbHelper.VERSION_COLUMN);
+ final Integer formatVersion = values.getAsInteger(MetadataDbHelper.FORMATVERSION_COLUMN);
+ final Integer flags = values.getAsInteger(MetadataDbHelper.FLAGS_COLUMN);
+ final String locale = values.getAsString(MetadataDbHelper.LOCALE_COLUMN);
+ if (null == id
+ || null == type
+ || null == description
+ || null == lastUpdate
+ || null == fileSize
+ || null == checksum
+ || null == localFilename
+ || null == remoteFilename
+ || null == version
+ || null == formatVersion
+ || null == flags
+ || null == locale) {
+ throw new IllegalArgumentException();
+ }
+ return new WordListMetadata(id, type, description, lastUpdate, fileSize, checksum,
+ localFilename, remoteFilename, version, formatVersion, flags, locale);
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder(WordListMetadata.class.getSimpleName());
+ sb.append(" : ").append(mId);
+ sb.append("\nType : ").append(mType);
+ sb.append("\nDescription : ").append(mDescription);
+ sb.append("\nLastUpdate : ").append(mLastUpdate);
+ sb.append("\nFileSize : ").append(mFileSize);
+ sb.append("\nChecksum : ").append(mChecksum);
+ sb.append("\nLocalFilename : ").append(mLocalFilename);
+ sb.append("\nRemoteFilename : ").append(mRemoteFilename);
+ sb.append("\nVersion : ").append(mVersion);
+ sb.append("\nFormatVersion : ").append(mFormatVersion);
+ sb.append("\nFlags : ").append(mFlags);
+ sb.append("\nLocale : ").append(mLocale);
+ return sb.toString();
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/WordListPreference.java b/java/src/com/android/inputmethod/dictionarypack/WordListPreference.java
new file mode 100644
index 000000000..0d923ae01
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/WordListPreference.java
@@ -0,0 +1,248 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.DialogPreference;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import com.android.inputmethod.latin.R;
+
+import java.util.Locale;
+
+/**
+ * A preference for one word list.
+ *
+ * This preference refers to a single word list, as available in the dictionary
+ * pack. Upon being pressed, it displays a menu to allow the user to install, disable,
+ * enable or delete it as appropriate for the current state of the word list.
+ */
+public final class WordListPreference extends DialogPreference {
+ static final private String TAG = WordListPreference.class.getSimpleName();
+
+ // What to display in the "status" field when we receive unknown data as a status from
+ // the content provider. Empty string sounds sensible.
+ static final private String NO_STATUS_MESSAGE = "";
+
+ /// Actions
+ static final private int ACTION_UNKNOWN = 0;
+ static final private int ACTION_ENABLE_DICT = 1;
+ static final private int ACTION_DISABLE_DICT = 2;
+ static final private int ACTION_DELETE_DICT = 3;
+
+ // Members
+ // The context to get resources
+ final Context mContext;
+ // The id of the client for which this preference is.
+ final String mClientId;
+ // The metadata word list id and version of this word list.
+ public final String mWordlistId;
+ public final int mVersion;
+ // The status
+ public int mStatus;
+
+ // Animation directions
+ static final private int ANIMATION_IN = 1;
+ static final private int ANIMATION_OUT = 2;
+
+ private static Button sLastClickedActionButton = null;
+ private final OnWordListPreferenceClick mPreferenceClickHandler =
+ new OnWordListPreferenceClick();
+ private final OnActionButtonClick mActionButtonClickHandler =
+ new OnActionButtonClick();
+
+ public WordListPreference(final Context context, final String clientId, final String wordlistId,
+ final int version, final Locale locale, final String description, final int status) {
+ super(context, null);
+ mContext = context;
+ mClientId = clientId;
+ mVersion = version;
+ mWordlistId = wordlistId;
+
+ setLayoutResource(R.layout.dictionary_line);
+
+ setTitle(description);
+ setStatus(status);
+ setKey(wordlistId);
+ }
+
+ private void setStatus(final int status) {
+ if (status == mStatus) return;
+ mStatus = status;
+ setSummary(getSummary(status));
+ // If we are currently displaying the dialog, we should update it, or at least
+ // dismiss it.
+ final Dialog dialog = getDialog();
+ if (null != dialog) {
+ dialog.dismiss();
+ }
+ }
+
+ private String getSummary(final int status) {
+ switch (status) {
+ // If we are deleting the word list, for the user it's like it's already deleted.
+ // It should be reinstallable. Exposing to the user the whole complexity of
+ // the delayed deletion process between the dictionary pack and Android Keyboard
+ // would only be confusing.
+ case MetadataDbHelper.STATUS_DELETING:
+ case MetadataDbHelper.STATUS_AVAILABLE:
+ return mContext.getString(R.string.dictionary_available);
+ case MetadataDbHelper.STATUS_DOWNLOADING:
+ return mContext.getString(R.string.dictionary_downloading);
+ case MetadataDbHelper.STATUS_INSTALLED:
+ return mContext.getString(R.string.dictionary_installed);
+ case MetadataDbHelper.STATUS_DISABLED:
+ return mContext.getString(R.string.dictionary_disabled);
+ default:
+ return NO_STATUS_MESSAGE;
+ }
+ }
+
+ private static final int sStatusActionList[][] = {
+ // MetadataDbHelper.STATUS_UNKNOWN
+ {},
+ // MetadataDbHelper.STATUS_AVAILABLE
+ { R.string.install_dict, ACTION_ENABLE_DICT },
+ // MetadataDbHelper.STATUS_DOWNLOADING
+ { R.string.cancel_download_dict, ACTION_DISABLE_DICT },
+ // MetadataDbHelper.STATUS_INSTALLED
+ { R.string.delete_dict, ACTION_DELETE_DICT },
+ // MetadataDbHelper.STATUS_DISABLED
+ { R.string.delete_dict, ACTION_DELETE_DICT },
+ // MetadataDbHelper.STATUS_DELETING
+ // We show 'install' because the file is supposed to be deleted.
+ // The user may reinstall it.
+ { R.string.install_dict, ACTION_ENABLE_DICT }
+ };
+
+ private CharSequence getButtonLabel(final int status) {
+ if (status >= sStatusActionList.length) {
+ Log.e(TAG, "Unknown status " + status);
+ return "";
+ }
+ return mContext.getString(sStatusActionList[status][0]);
+ }
+
+ private static int getActionIdFromStatusAndMenuEntry(final int status) {
+ if (status >= sStatusActionList.length) {
+ Log.e(TAG, "Unknown status " + status);
+ return ACTION_UNKNOWN;
+ }
+ return sStatusActionList[status][1];
+ }
+
+ private void disableDict() {
+ SharedPreferences prefs = CommonPreferences.getCommonPreferences(mContext);
+ CommonPreferences.disable(prefs, mWordlistId);
+ UpdateHandler.markAsUnused(mContext, mClientId, mWordlistId, mVersion, mStatus);
+ if (MetadataDbHelper.STATUS_DOWNLOADING == mStatus) {
+ setStatus(MetadataDbHelper.STATUS_AVAILABLE);
+ } else if (MetadataDbHelper.STATUS_INSTALLED == mStatus) {
+ // Interface-wise, we should no longer be able to come here. However, this is still
+ // the right thing to do if we do come here.
+ setStatus(MetadataDbHelper.STATUS_DISABLED);
+ } else {
+ Log.e(TAG, "Unexpected state of the word list for disabling " + mStatus);
+ }
+ }
+ private void enableDict() {
+ SharedPreferences prefs = CommonPreferences.getCommonPreferences(mContext);
+ CommonPreferences.enable(prefs, mWordlistId);
+ // Explicit enabling by the user : allow downloading on metered data connection.
+ UpdateHandler.markAsUsed(mContext, mClientId, mWordlistId, mVersion, mStatus, true);
+ if (MetadataDbHelper.STATUS_AVAILABLE == mStatus) {
+ setStatus(MetadataDbHelper.STATUS_DOWNLOADING);
+ } else if (MetadataDbHelper.STATUS_DISABLED == mStatus
+ || MetadataDbHelper.STATUS_DELETING == mStatus) {
+ // If the status is DELETING, it means Android Keyboard
+ // has not deleted the word list yet, so we can safely
+ // turn it to 'installed'. The status DISABLED is still supported internally to
+ // avoid breaking older installations and all but there should not be a way to
+ // disable a word list through the interface any more.
+ setStatus(MetadataDbHelper.STATUS_INSTALLED);
+ } else {
+ Log.e(TAG, "Unexpected state of the word list for enabling " + mStatus);
+ }
+ }
+ private void deleteDict() {
+ SharedPreferences prefs = CommonPreferences.getCommonPreferences(mContext);
+ CommonPreferences.disable(prefs, mWordlistId);
+ setStatus(MetadataDbHelper.STATUS_DELETING);
+ UpdateHandler.markAsDeleting(mContext, mClientId, mWordlistId, mVersion, mStatus);
+ }
+
+ @Override
+ protected void onBindView(final View view) {
+ super.onBindView(view);
+ ((ViewGroup)view).setLayoutTransition(null);
+ final Button button = (Button)view.findViewById(R.id.wordlist_button);
+ button.setText(getButtonLabel(mStatus));
+ button.setVisibility(View.INVISIBLE);
+ button.setOnClickListener(mActionButtonClickHandler);
+ view.setOnClickListener(mPreferenceClickHandler);
+ }
+
+ private class OnWordListPreferenceClick implements View.OnClickListener {
+ @Override
+ public void onClick(final View v) {
+ final Button button = (Button)v.findViewById(R.id.wordlist_button);
+ if (null != sLastClickedActionButton) {
+ animateButton(sLastClickedActionButton, ANIMATION_OUT);
+ }
+ animateButton(button, ANIMATION_IN);
+ sLastClickedActionButton = button;
+ }
+ }
+
+ private void animateButton(final Button button, final int direction) {
+ final float outerX = ((View)button.getParent()).getWidth();
+ final float innerX = button.getX() - button.getTranslationX();
+ if (View.INVISIBLE == button.getVisibility()) {
+ button.setTranslationX(outerX - innerX);
+ button.setVisibility(View.VISIBLE);
+ }
+ if (ANIMATION_IN == direction) {
+ button.animate().translationX(0);
+ } else {
+ button.animate().translationX(outerX - innerX);
+ }
+ }
+
+ private class OnActionButtonClick implements View.OnClickListener {
+ @Override
+ public void onClick(final View v) {
+ switch (getActionIdFromStatusAndMenuEntry(mStatus)) {
+ case ACTION_ENABLE_DICT:
+ enableDict();
+ break;
+ case ACTION_DISABLE_DICT:
+ disableDict();
+ break;
+ case ACTION_DELETE_DICT:
+ deleteDict();
+ break;
+ default:
+ Log.e(TAG, "Unknown menu item pressed");
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/inputmethod/latin/BinaryDictionary.java b/java/src/com/android/inputmethod/latin/BinaryDictionary.java
index d369e2b47..7383862b1 100644
--- a/java/src/com/android/inputmethod/latin/BinaryDictionary.java
+++ b/java/src/com/android/inputmethod/latin/BinaryDictionary.java
@@ -19,6 +19,7 @@ package com.android.inputmethod.latin;
import android.text.TextUtils;
import android.util.SparseArray;
+import com.android.inputmethod.dictionarypack.DictionaryProvider;
import com.android.inputmethod.keyboard.ProximityInfo;
import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
@@ -31,8 +32,7 @@ import java.util.Locale;
*/
public final class BinaryDictionary extends Dictionary {
private static final String TAG = BinaryDictionary.class.getSimpleName();
- public static final String DICTIONARY_PACK_AUTHORITY =
- "com.android.inputmethod.latin.dictionarypack";
+ public static final String DICTIONARY_PACK_AUTHORITY = DictionaryProvider.AUTHORITY;
// Must be equal to MAX_WORD_LENGTH in native/jni/src/defines.h
private static final int MAX_WORD_LENGTH = Constants.Dictionary.MAX_WORD_LENGTH;
diff --git a/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java b/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
index f68e9f90b..294312843 100644
--- a/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
+++ b/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
@@ -96,18 +96,8 @@ final class BinaryDictionaryGetter {
private static final class DictPackSettings {
final SharedPreferences mDictPreferences;
public DictPackSettings(final Context context) {
- Context dictPackContext = null;
- try {
- final String dictPackName =
- context.getString(R.string.dictionary_pack_package_name);
- dictPackContext = context.createPackageContext(dictPackName, 0);
- } catch (NameNotFoundException e) {
- // The dictionary pack is not installed...
- // TODO: fallback on the built-in dict, see the TODO above
- Log.e(TAG, "Could not find a dictionary pack");
- }
- mDictPreferences = null == dictPackContext ? null
- : dictPackContext.getSharedPreferences(COMMON_PREFERENCES_NAME,
+ mDictPreferences = null == context ? null
+ : context.getSharedPreferences(COMMON_PREFERENCES_NAME,
Context.MODE_WORLD_READABLE | Context.MODE_MULTI_PROCESS);
}
public boolean isWordListActive(final String dictId) {
diff --git a/java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java b/java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java
index a8513ff45..d6c88910f 100644
--- a/java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java
+++ b/java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java
@@ -16,6 +16,8 @@
package com.android.inputmethod.latin;
+import com.android.inputmethod.dictionarypack.UpdateHandler;
+
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
@@ -34,7 +36,7 @@ public final class DictionaryPackInstallBroadcastReceiver extends BroadcastRecei
* The action of the intent for publishing that new dictionary data is available.
*/
/* package */ static final String NEW_DICTIONARY_INTENT_ACTION =
- "com.android.inputmethod.latin.dictionarypack.newdict";
+ UpdateHandler.NEW_DICTIONARY_INTENT_ACTION;
public DictionaryPackInstallBroadcastReceiver(final LatinIME service) {
mService = service;
diff --git a/java/src/com/android/inputmethod/latin/SettingsFragment.java b/java/src/com/android/inputmethod/latin/SettingsFragment.java
index 840829c24..fa17b4ffc 100644
--- a/java/src/com/android/inputmethod/latin/SettingsFragment.java
+++ b/java/src/com/android/inputmethod/latin/SettingsFragment.java
@@ -30,6 +30,7 @@ import android.preference.PreferenceGroup;
import android.preference.PreferenceScreen;
import android.view.inputmethod.InputMethodSubtype;
+import com.android.inputmethod.dictionarypack.DictionarySettingsActivity;
import com.android.inputmethod.latin.define.ProductionFlag;
import com.android.inputmethod.latin.setup.LauncherIconVisibilityManager;
import com.android.inputmethodcommon.InputMethodSettingsFragment;
@@ -146,6 +147,7 @@ public final class SettingsFragment extends InputMethodSettingsFragment
final PreferenceScreen dictionaryLink =
(PreferenceScreen) findPreference(Settings.PREF_CONFIGURE_DICTIONARIES_KEY);
final Intent intent = dictionaryLink.getIntent();
+ intent.setClassName(context.getPackageName(), DictionarySettingsActivity.class.getName());
final int number = context.getPackageManager().queryIntentActivities(intent, 0).size();
// TODO: The development-only-diagnostic version is not supported by the Dictionary Pack
// Service yet
diff --git a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java
index 63f46b79e..9a1114f7f 100644
--- a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java
+++ b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java
@@ -16,6 +16,7 @@
package com.android.inputmethod.latin.spellcheck;
+import android.os.Binder;
import android.text.TextUtils;
import android.util.Log;
import android.view.textservice.SentenceSuggestionsInfo;
@@ -133,22 +134,27 @@ public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheck
@Override
public SuggestionsInfo[] onGetSuggestionsMultiple(TextInfo[] textInfos,
int suggestionsLimit, boolean sequentialWords) {
- final int length = textInfos.length;
- final SuggestionsInfo[] retval = new SuggestionsInfo[length];
- for (int i = 0; i < length; ++i) {
- final String prevWord;
- if (sequentialWords && i > 0) {
+ long ident = Binder.clearCallingIdentity();
+ try {
+ final int length = textInfos.length;
+ final SuggestionsInfo[] retval = new SuggestionsInfo[length];
+ for (int i = 0; i < length; ++i) {
+ final String prevWord;
+ if (sequentialWords && i > 0) {
final String prevWordCandidate = textInfos[i - 1].getText();
// Note that an empty string would be used to indicate the initial word
// in the future.
prevWord = TextUtils.isEmpty(prevWordCandidate) ? null : prevWordCandidate;
- } else {
- prevWord = null;
+ } else {
+ prevWord = null;
+ }
+ retval[i] = onGetSuggestionsInternal(textInfos[i], prevWord, suggestionsLimit);
+ retval[i].setCookieAndSequence(textInfos[i].getCookie(),
+ textInfos[i].getSequence());
}
- retval[i] = onGetSuggestions(textInfos[i], prevWord, suggestionsLimit);
- retval[i].setCookieAndSequence(textInfos[i].getCookie(),
- textInfos[i].getSequence());
+ return retval;
+ } finally {
+ Binder.restoreCallingIdentity(ident);
}
- return retval;
}
}
diff --git a/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java b/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java
index cd3f9e442..4f86a3175 100644
--- a/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java
+++ b/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java
@@ -18,6 +18,7 @@ package com.android.inputmethod.latin.spellcheck;
import android.content.ContentResolver;
import android.database.ContentObserver;
+import android.os.Binder;
import android.provider.UserDictionary.Words;
import android.service.textservice.SpellCheckerService.Session;
import android.text.TextUtils;
@@ -234,13 +235,12 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session {
* corrections for the text passed as an argument. It may split or group words, and
* even perform grammatical analysis.
*/
- @Override
- public SuggestionsInfo onGetSuggestions(final TextInfo textInfo,
+ private SuggestionsInfo onGetSuggestionsInternal(final TextInfo textInfo,
final int suggestionsLimit) {
- return onGetSuggestions(textInfo, null, suggestionsLimit);
+ return onGetSuggestionsInternal(textInfo, null, suggestionsLimit);
}
- protected SuggestionsInfo onGetSuggestions(
+ protected SuggestionsInfo onGetSuggestionsInternal(
final TextInfo textInfo, final String prevWord, final int suggestionsLimit) {
try {
final String inText = textInfo.getText();
@@ -357,4 +357,22 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session {
}
}
}
+
+ /*
+ * The spell checker acts on its own behalf. That is needed, in particular, to be able to
+ * access the dictionary files, which the provider restricts to the identity of Latin IME.
+ * Since it's called externally by the application, the spell checker is using the identity
+ * of the application by default unless we clearCallingIdentity.
+ * That's what the following method does.
+ */
+ @Override
+ public SuggestionsInfo onGetSuggestions(final TextInfo textInfo,
+ final int suggestionsLimit) {
+ long ident = Binder.clearCallingIdentity();
+ try {
+ return onGetSuggestionsInternal(textInfo, suggestionsLimit);
+ } finally {
+ Binder.restoreCallingIdentity(ident);
+ }
+ }
}