Vyvíjíme pro Android: Notifikace, broadcast receivery a Internet

V dnešním nabitém dílu seriálu Vyvíjíme pro Android se naučíme pracovat s notifikacemi (včetně Jelly Bean novinek), broadcast receivery a Internetem, a to všechno při vytváření aplikace hlídající články na Zdrojáku.
Seriál: Vyvíjíme pro Android (14 dílů)
- Vyvíjíme pro Android: Začínáme 15. 6. 2012
- Vyvíjíme pro Android: První krůčky 22. 6. 2012
- Vyvíjíme pro Android: Suroviny, Intenty a jednotky 29. 6. 2012
- Vyvíjíme pro Android: Bližší pohled na pohledy – 1. díl 13. 7. 2012
- Vyvíjíme pro Android: Bližší pohled na pohledy – 2. díl 13. 7. 2012
- Vyvíjíme pro Android: Fragmenty a SQLite databáze 20. 7. 2012
- Vyvíjíme pro Android: Preference, menu a vlastní Adapter 27. 7. 2012
- Vyvíjíme pro Android: Intenty, intent filtry a permissions 3. 8. 2012
- Vyvíjíme pro Android: Content providery 10. 8. 2012
- Vyvíjíme pro Android: Dialogy a activity 17. 8. 2012
- Vyvíjíme pro Android: Stylování a design 24. 8. 2012
- Vyvíjíme pro Android: Notifikace, broadcast receivery a Internet 31. 8. 2012
- Vyvíjíme pro Android: Nahraváme aplikaci na Google Play Store 7. 9. 2012
- Vyvíjíme pro Android: Epilog 14. 9. 2012
Nálepky:
V minulém díle jsme se věnovali design guidelines a stylování. Dnes se naučíme pracovat s notifikacemi (a to včetně novinek z Jelly Bean, ale přitom zůstaneme zpětně kompatibilní), broadcast receivery a Internetem. Naučíme se spouštět nějaký kód každý den ve stejnou dobu, procvičíme si content providery a vytvoříme si vlastní Adapter.
To všechno na jedné ukázkové aplikaci, jíž jsem nazval Nové články na Zdrojáku. Ta každý den po půlnoci stáhne RSS článků Zdrojáku, zjistí, jsou-li nějaké nové, a pokud ano, zobrazí notifikaci. Kromě toho ještě bude obsahovat ListActivity se seznamem článků, po kliknutí na některý z nich se článek otevře v prohlížeči.
Dnes je článek opravdu hodně nabitý. Přemýšlel jsem, jestli vynechat části kódu, ale pak jsem si řekl, že jednak už jste na Androidu dost zkušení, jednak může být docela zajímavé vyvíjet aplikaci, která sleduje nějaký praktický účel, a zobrazení jen nových věcí by tomu uškodilo. Takže nové věci samozřejmě vysvětlím, ale staré a známé (anebo jednoduše nalezitelné v dokumentaci) nechám víceméně bez komentáře.
Broadcast receivery
Než ale začneme pracovat, bylo by dobré seznámit se s nástroji. Začneme broadcast receivery.
Díky broadcast receiverům můžeme provést nějakou akci třeba když se změní stav připojení k internetu. Nebo když se zapne displej. Nebo si můžeme vytvořit nějakou vlastní událost, tu předat frameworku a on upozorní všechny broadcast receivery, které deklarovaly, že se o danou událost zajímají. Pokud jste pracovali s nějakým event-driven jazykem (třeba JavaScript), je vám to určitě povědomé.
Samotný broadcast receiver dědí od třídy BroadcastReceiver
(na odkazované adrese je i lidsky čitelná dokumentace, nejen API reference) a implementuje metodu onReceive(Context context, Intent intent)
. Ta se spustí vždy, když se broadcast receiver aktivuje.
Broadcast receiver se aktivuje tak, že někdo vytvoří obyčejný Intent
s obyčejnou action
(a čímkoli dalším), který předá metodě Context.sendBroadcast()
(nebo sendOrderedBroadcast()
či sendStickyBroadcast()
, kterým se věnovat nebudeme). O zbytek se postará framework.
A jak se o to postará? Jak pozná, které broadcast receivery upozornit? Broadcast receiver musíte zapsat v manifestu, kde stejně jako <activity>
může obsahovat elementy <intent-filter>
. Pokud Intent filtrem projde, broadcast se spustí (a ten Intent dostane jako parametr).
Ve skutečnosti v manifestu zapsán být nemusí. Celkem běžné využití broadcast receiverů je i v rámci Activity, kde sledují například změny nějakých vnějších faktorů a podle toho se Activity pak chová. Tehdy je možné vytvořit objekt IntentFilter
a společně s instancí BroadcastReceiver
-u ho předat metodě registerReceiver()
. Při zničení Activity se musí receiver zase odregistrovat ( unregisterReceiver()
). I to dnes použijeme.
Notifikace
Stránka dokumentace věnující se notifikacím je zoufale zastaralá, takže na ni ani nebudu odkazovat. Odkážu ale na design guidelines a hlavně na icon design guidelines, část věnující se statusbarovým ikonám, kde je výborně vysvětleno, které všechny ikony musíte pro notifikaci vytvořit, aby to vypadalo dobře na všech verzích Androidu (to my dnes zanedbáme).
Nikdy byste neměli z nějakého procesu běžícího na pozadí (ať už broadcast receiveru, service nebo čehokoli jiného) přímo spouštět nějakou Activity. To by uživatele pěkně naštvalo. Místo toho vyrobte notifikaci, on se o všem dozví a Activity spustí, až bude sám chtít (nebo nespustí, když chtít nebude).
Notifikace se postupně s Androidem měnily. Měnil se vzhled ikon, trochu se měnil i vzhled notifikací, hodně se změnil způsob jejich vytváření (o čemž článek v dokumentaci neví), který adoptovala i support library. Díky ní můžeme jednoduše vytvořit notifikaci, která používá novinky z Jelly Bean (což je verze, která přinesla zdaleka největší změnu v notifikacích), ale přitom bude fungovat i na Androidu 1.6.
Dejte si pozor na to, abyste měli nejnovější verzi support library. Některé věci, které budeme používat, se do ní totiž dostaly opravdu nedávno. S tím ale bohužel přichází i to, že v ní jsou bugy. Nezkoušel jsem, jestli je to v mé verzi už opravené, vzhledem ke stáří ticketu bych řekl, že ne. Ale přesto vám to chci ukázat, takže se smíříme s tím, že na Androidu 3.2 aplikace nejspíš nebude fungovat. A budeme čekat, než vyjde nová verze support library.
Notifikaci vytvoříme pomocí třídy NotificationCompat.Builder
, metodou build()
získáme instanci Notification
. Tu potom zobrazíme pomocí NotificationManager
-u:
Notification n; NotificationCompat.Builder builder = new NotificationCompat.Builder(context); n = builder .setContentTitle(R.string.title) .setContentText(R.string.text) .setSmallIcon(R.drawable.notification) .build(); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.notify(NOTIFICATION_ID, n);
Práce s Internetem
Pro práci s Internetem potřebujeme permission android.permission.INTERNET
.
Nikdy nesmíme pracovat na UI vlákně. V našem případě nám bude stačit obyčejný Thread
, ale často se hodí androidí AsyncTask
. AsyncTask (v dokumentaci na stránce Processes and threads) usnadňuje komunikaci mezi UI a worker vláknem, posílání informací o progressu a formalizuje „životní cyklus” pracovního vlákna – před spuštěním, pak běží samotné vlákno, které může zveřejňovat svůj pokrok, po doběhnutí se pracuje s daty zase na UI vlákně. Bohužel dnes na AsyncTask není místo, snad jindy.
Pro samotné vytváření a volání nějakých požadavků (requestů) doporučují třídu HttpURLConnection
, která má však různé problémy, takže bychom zbytečně plýtvali energií na workaroundy. Proto dnes použijeme starý dobrý DefaultHttpClient
:
String responseStr = null; try { DefaultHttpClient httpClient = new DefaultHttpClient(); HttpGet get = new HttpGet(url); HttpResponse httpResponse = httpClient.execute(get); HttpEntity httpEntity = httpResponse.getEntity(); responseStr = EntityUtils.toString(httpEntity); } catch (UnsupportedEncodingException e) { } catch (ClientProtocolException e) { } catch (IOException e) { }
AlarmManager
Potřebujete spouštět nějakou akci pravidelně třeba každý den v určitou hodinu? Na to slouží AlarmManager
. Její metoda setRepeating(int type, long triggerAtMillis, long intervalMillis, PendingIntent operation)
přebírá následující argumenty:
type
je jedna ze čtyř konstant AlarmManager.ELAPSED_REALTIME
, AlarmManager.ELAPSED_REALTIME_WAKEUP
, AlarmManager.RTC
a AlarmManager.RTC_WAKEUP
. Rozdíl mezi ELAPSED_REALTIME
a RTC
je v tom, že u ELAPSED_REALTIME
se čas předaný triggerAtMillis
počítá od nabootování zařízení, RTC
přijímá Unixovou časovou známku (počet milisekund od půlnoci 1. 1. 1970). Rozdíl mezi WAKEUP
a ne- WAKEUP
variantami je ten, že s WAKEUP
proběhne akce přibližně přesně v předanou dobu, a pokud je potřeba, zařízení je probuzeno, zatímco varianta bez WAKEUP
se v případě, že telefon spinká, odloží a provede se až tehdy, když se telefon probudí.
triggerAtMillis
je timestamp, kdy se akce spustí poprvé.
intervalMillis
je časové rozmezí jednotlivých opakování akce (v milisekundách). AlarmManager nabízí některé předdefinované konstanty.
operation
reprezentuje samotnou akci a povíme si o tom později.
AlarmManager
získáte jako systémovou službu
pomocí ALARM_SERVICE
.
// Spustí se půl hodiny po nejbližší další půlnoci a pak už každý den ve stejnou dobu. Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(System.currentTimeMillis()); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MINUTE, 30); calendar.set(Calendar.HOUR, 0); calendar.set(Calendar.AM_PM, Calendar.AM); calendar.add(Calendar.DAY_OF_MONTH, 1); ((AlarmManager) getSystemService(Context.ALARM_SERVICE)).setRepeating( AlarmManager.RTC, calendar.getTimeInMillis(), AlarmManager.INTERVAL_DAY, pi);
PendingIntent
Asi jste si všimli proměnné pi
v předchozí ukázce. Není to π, jde o zkratku slova pending intent.
O PendingIntent
-ech se dá přemýšlet v podstatě jako o Intentech, které mají navíc informaci o tom, jakou metodou se mají spustit (jestli startActivity()
, sendBroadcast()
nebo třeba startService()
.
Intent intent = new Intent(this, UpdateReceiver.class); intent.putExtra("someExtra", true); PendingIntent pi = PendingIntent.getBroadcast(this, 0, intent, 0);
Tahle ukážka vytvoří PendingIntent pro nějaký BroadcastReceiver. Třídu UpdateReceiver
si za chvíli implementujeme, není vestavěná, v dokumentaci ji nehledejte.
A jdeme programovat!
Nejdřív ale asi stojí za to si promyslet, co vlastně chceme dělat (to už víceméně máme) a jak to chceme dělat.
Určitě bude potřeba třída, která stáhne RSS Zdrojáku z Internetu. Potom nějaká třída, která ho rozparsuje a vytáhne z něj potřebné věci. Abychom neztráceli čas pro náš účel nedůležitými věcmi, možná obětujeme čistotu kódu, testovatelnost a efektivitu, ale parsovací třída vrátí pole objektů ContentValues
, pro každý článek jeden objekt, všechno připravené na vložení do content provideru. Třída, která stahuje data, na ně (opět asi ne úplně nejčistší návrh) rovnou zavolá parser a pole vrácené parserem předá content provideru. Pro parser použijeme DOM ( org.w3c.dom
) – nějaký proudový parser by byl určitě efektivnější, ale kód s DOMem se nám minimálně bude lépe číst. A vzhledem k tomu, že Zdroják má v RSS deset posledních článků, výkonnostní nebo paměťové problémy nám DOM určitě působit nebude.
V souladu s principem DRY by asi bylo, abychom vytvořili obecný RSS parser a potom třídy, které by s jeho výsledky nějak pracovaly. To by ale zabralo spoustu času a vůbec bychom se dnes nedostali k zajímavým věcem, takže parser je přímo na míru Zdrojákovému RSS a i na jiných místech jsme si práci usnadnili.
Ukládání dat vyřešíme pomocí content provideru. U každého článku si kromě dat z RSS budeme pamatovat, jestli ho uživatel přečetl a také, kdy byl stažen do telefonu (kvůli notifikacím). Content provider od stahovače může dostat i články, které už má uložené, a musí si s tím poradit. Vyřešíme to tak, že uložíme pouze články novější než nejnovější uložený. Abychom si to zjednodušili, toto filtrování uděláme jen v metodě bulkInsert()
. Protože ale těžko bude zapisovat někdo jiný než my (číst by ale klidně nějaká aplikace mohla. Třeba taková, která dá na plochu widget s počtem nepřečtených článků), můžeme si to dovolit.
Naprostá většina content provideru je ale zkopírována z provideru, který jsme vytvořili v díle Content providery.
Po jakékoli změně dat vypustí content provider vysílání (broadcast) informující o změně dat.
Tomu bude naslouchat mimo jiné i ListActivity, které potom obnoví svá data (jde to i jinak, ale na to není místo, takhle alespoň ukážu broadcast receiver registrovaný v kódu). Ta bude zobrazovat seznam článků na Zdrojáku, a to ne jen titulek, jako třeba Google Reader, ale i perex. V podstatě se bude snažit „vypadat jako” mobilní stránky Zdrojáku. Na to budeme potřebovat vlastní Adapter.
Kromě toho nabídne Activity položku menu na nastavení všech článků jako přečtené, zařídí updatování vždy o půlnoci (pokud je v AlarmManageru stejný PendingIntent, jeho alarm se zruší a nahradí se nově nastaveným. Takže žádný problém s mnohonásobným spouštěním broadcast receiverů) a do SharedPreferences
uloží informaci o tom, kdy byla Activity naposledy zobrazena (také info pro notifikaci). Při uložení této informace také vyšle broadcast.
Nakonec zbývají ještě dva broadcast receivery. UpdateReceiver
se bude starat o stáhnutí RSS, a předání získaných dat content provideru. Bude volán vždy o půlnoci, ale také vždy, když se změní stav připojení k Internetu (to proto, kdyby o půlnoci připojení nebylo). Pokud však UpdateReceiver už ten den RSS stáhl, nic znovu stahovat nebude.
Úplně nakonec nám zbyl NotificationReceiver
. Ten bude naslouchat jednak změnám dat v content provideru a jednak broadcastům od Activity o jejím zobrazení. Vždy při spuštění notifikaci odstraní, ale pokud existuje alespoň jeden článek, který se objevil (stáhl) poté, co byla Activity naposledy spuštěna, zobrazí (jinou) notifikaci znovu. Pokud je takový článek jen jeden, bude v notifikaci jeho titulek, jméno autora a na Jelly Bean se při roztažení notifikace zobrazí perex. Pokud je takových článků víc, zobrazí se v titulku informace, že jsou dostupné nové články, v „nerozvinutém” těle bude věta typu „Máte 7 nepřečtených článků.” a v „rozvinutém” budou titulky maximálně pěti nepřečtených článků. Po kliknutí na notifikaci se zobrazí Activity.
Máme rozmyšleno, jdeme opravdu programovat!
A teď bude hodně Javy, trochu XML a málo češtiny. Všechno už v podstatě víte :).
RSSParser
Třída RSSParser
parsuje RSS zdrojáku a vrací ContentValues[]
. Kromě samotných parsovacích funkcí budu muset převést datum z elementu <pubdate>
na timestamp a také zkrátím jméno autora (z redakce@zdrojak.cz (Zdroják: John Doe)
na John Doe
). Protože je metoda getTextContent()
až od API 8, vytvořím si vlastní:
Kontrolu, jestli mě aktuální element zajímá a zároveň převedení jeho jména na klíč content provideru vyřeším pomocí HashMap
.
public class RSSParser { private String rss; private Map<String, String> keys; private SimpleDateFormat format; public RSSParser(String rss) { this.rss = rss; format = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss z", Locale.US); keys = new HashMap<String, String>(); keys.put("title", ZdrojakContract.TITLE); keys.put("link", ZdrojakContract.LINK); keys.put("description", ZdrojakContract.DESCRIPTION); keys.put("author", ZdrojakContract.AUTHOR); keys.put("pubDate", ZdrojakContract.PUBDATE); }
Metoda parse()
se stará o samotné parsování a používá některé pomocné metody, jež ukážu za chvilku:
public ContentValues[] parse() throws RSSParserException { Document doc = getDOM(rss); List list = new ArrayList(); NodeList items = doc.getElementsByTagName("item"); Node item; ContentValues values; NodeList children; Node child; for (int i = 0; (item = items.item(i)) != null; i++) { values = new ContentValues(); children = item.getChildNodes(); for (int j = 0; (child = children.item(j)) != null; j++) { if (child.getNodeType() == Node.ELEMENT_NODE) processNode(values, child); } list.add(values); } return list.toArray(new ContentValues[0]); }
ProcessNode()
se volá na každém elementovém uzlu:
private void processNode(ContentValues values, Node node) { String key = getKeyForTagName(node.getNodeName()); if (key == null) return; String nodeValue = getNodeTextContent(node); if (key.equals(ZdrojakContract.PUBDATE)) { long pubDate = 0; try { pubDate = format.parse(nodeValue).getTime(); } catch (ParseException e) { e.printStackTrace(); } values.put(key, pubDate); } else if (key.equals(ZdrojakContract.AUTHOR)) { String authorName = nodeValue.replaceFirst( "redakce@zdrojak\.cz \(Zdroják: (.*)\).*", "$1"); values.put(key, authorName); } else { values.put(key, nodeValue); } }
A nakonec tři pomocné funkce:
private Document getDOM(String rss) throws RSSParserException { Document doc = null; DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); try { DocumentBuilder db = dbf.newDocumentBuilder(); InputSource is = new InputSource(); is.setCharacterStream(new StringReader(rss)); doc = db.parse(is); return doc; } catch (ParserConfigurationException e) { } catch (SAXException e) { } catch (IOException e) { } throw new RSSParserException(); } private String getKeyForTagName(String tagName) { if (keys.containsKey(tagName)) return keys.get(tagName); else return null; } private String getNodeTextContent(Node node) { String ret = ""; NodeList children = node.getChildNodes(); Node child; for (int i = 0; (child = children.item(i)) != null; i++) { if (child.getNodeType() == Node.TEXT_NODE) { if (!ret.equals("")) ret += " "; ret += child.getNodeValue(); } } return ret; }
Třídu RSSParser
jsem vytvářel s ohledem na rychlost (mou, ne její), stručnost a co nejjednodušší srozumitelnost. Doufám, že v opravdu reálném neukázkovém projektu byste si s ní poradili lépe než já zde.
ZdrojakRSSHandler
Přestože třída končí na Handler
, není potomkem Handler
-u. Místo toho se stará o stažení RSS, jeho předání parseru a pak předání rozparsovaných dat content provideru:
public class ZdrojakRSSHandler { private static final String url = "https://www.zdrojak.cz/rss/clanky/"; private Context ctx; public ZdrojakRSSHandler(Context ctx) { this.ctx = ctx; } public int updateData() throws ZdrojakRSSHandlerException, RSSParserException{ String rss = getStringData(); RSSParser parser = new RSSParser(rss); ContentValues[] values = parser.parse(); return ctx.getContentResolver().bulkInsert(ZdrojakContract.CONTENT_URI_ARTICLES, values); } private String getStringData() throws ZdrojakRSSHandlerException { String xml = null; try { DefaultHttpClient httpClient = new DefaultHttpClient(); HttpGet get = new HttpGet(url); HttpResponse httpResponse = httpClient.execute(get); HttpEntity httpEntity = httpResponse.getEntity(); xml = EntityUtils.toString(httpEntity); return xml; } catch (UnsupportedEncodingException e) { } catch (ClientProtocolException e) { } catch (IOException e) { } throw new ZdrojakRSSHandlerException(); } }
RSSParserException
i ZdrojakRSSHandlerException
jsou potomky Exception
, ukazovat je nebudu.
ZdrojakContract
Abychom mohli implementovat content provider, musíme znát contract class. Ta kromě běžných položek nabízí i statickou metodu setViewed()
, neboť to může být docela častý úkon a taková pomocná metoda práci jistě usnadní. Navíc jsem do ZdrojakContract
přidal i konstanty pro naše broadcast actions.
public class ZdrojakContract { public static final String _ID = "_id"; public static final String TITLE = "title"; public static final String LINK = "link"; public static final String DESCRIPTION = "desc"; public static final String AUTHOR = "author"; public static final String PUBDATE = "pubdate"; public static final String VIEWED = "viewed"; public static final String ADDED = "added"; public static final String AUTHORITY = "com.example.zdrojaknotifier.provider"; public static final String ARTICLES = "articles"; public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY); public static final Uri CONTENT_URI_ARTICLES = Uri.withAppendedPath(CONTENT_URI, ARTICLES); public static final String[] PROJECTION = { _ID, TITLE, LINK, DESCRIPTION, AUTHOR, PUBDATE, VIEWED }; public static final String DEFAULT_SORT_ORDER = _ID + " DESC"; public static final String SORT_ORDER_PUBDATE = PUBDATE + " DESC"; public static final String MIME_TYPE_ITEM = "vnd.android.cursor.item/vnd.com.example.zdrojaknotifier.article"; public static final String MIME_TYPE_DIR = "vnd.android.cursor.dir/vnd.com.example.zdrojaknotifier.article"; public static void setViewed(Context ctx, long id, boolean viewed){ Uri uri = ContentUris.withAppendedId(CONTENT_URI_ARTICLES, id); ContentValues values = new ContentValues(); values.put(VIEWED, viewed); ctx.getContentResolver().update(uri, values, null, null); } // Netýká se content providerů, tohle je Action našeho broadcastu. public static final String ACTION_ZDROJAK_UPDATED = "com.example.zdrojaknotifier.action.ZDROJAK_UPDATED"; public static final String ACTION_LASTVIEW_CHANGED = "com.example.zdrojaknotifier.action.LASTVIEW_CHANGED"; }
ZdrojakProvider
Pokud si pamatujete NotesProvider
za článku Content providery, ZdrojakProvider
vás rozhodně nepřekvapí. Většinu kódu jsem dokonce z NotesProvider
-u zkopíroval, u nových věcí se pozastavím:
Na konstantách, open helperu ani metodě delete()
(kterou potřebujeme v podstatě jen pro testovací účely) není nic nového:
public class ZdrojakProvider extends ContentProvider { private static final int ARTICLES = 0; private static final int ARTICLE_ID = 1; private static final String[] MANDATORY_COLUMNS = { ZdrojakContract.TITLE, ZdrojakContract.LINK, ZdrojakContract.DESCRIPTION, ZdrojakContract.AUTHOR, ZdrojakContract.PUBDATE }; private static final UriMatcher uriMatcher = new UriMatcher( UriMatcher.NO_MATCH); static { uriMatcher.addURI(ZdrojakContract.AUTHORITY, ZdrojakContract.ARTICLES, ARTICLES); uriMatcher.addURI(ZdrojakContract.AUTHORITY, ZdrojakContract.ARTICLES + "/#", ARTICLE_ID); } protected static final String DATABASE_NAME = "zdrojak"; protected static final int DATABASE_VERSION = 2; protected static final String TB_NAME = "articles"; private SQLiteOpenHelper openHelper; static class DatabaseHelper extends SQLiteOpenHelper { DatabaseHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE " + TB_NAME + " (" + ZdrojakContract._ID + " INTEGER PRIMARY KEY," + ZdrojakContract.TITLE + " TEXT NOT NULL," + ZdrojakContract.LINK + " TEXT NOT NULL," + ZdrojakContract.DESCRIPTION + " TEXT NOT NULL," + ZdrojakContract.AUTHOR + " TEXT NOT NULL," + ZdrojakContract.PUBDATE + " INTEGER NOT NULL," + ZdrojakContract.VIEWED + " BOOLEAN NOT NULL DEFAULT 0, " + ZdrojakContract.ADDED + " INTEGER NOT NULL" + ");"); } /* * Bylo by lepší vytvořit pro každou změnu struktury databáze nějaký * upgradovací nedestruktivní SQL příkaz, ačkoli tady ztráta dat tolik * nebolí. */ @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS " + TB_NAME); onCreate(db); } } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { SQLiteDatabase db = openHelper.getWritableDatabase(); int count; switch (uriMatcher.match(uri)) { case ARTICLES: count = db.delete(TB_NAME, selection, selectionArgs); break; case ARTICLE_ID: // Musíme vytvořít podmínku WHERE pro dané ID, ale zároveň musíme // zachovat případné where předané uživatelem. String[] newArgs = createSelectionArgsWithId(selectionArgs, uri .getPathSegments().get(1)); String where = createSelectionWithId(selection); count = db.delete(TB_NAME, where, newArgs); break; default: throw new IllegalArgumentException("Unknown URI " + uri); } // Vrátíme počet smazaných řádků return count; }
Metoda insert()
volá pomocnou funkci checkMandatoryColumns()
, která zjistí přítomnost všech povinných sloupců a případně vyhodí výjimku:
@Override public Uri insert(Uri uri, ContentValues values) { // Neměl by se používat sám o sobě - nekontroluje, jestli tam článek už // není a také neobnovuje notifikaci. if (uriMatcher.match(uri) != ARTICLES) { throw new IllegalArgumentException("Unknown URI " + uri); } checkMandatoryColumns(values); if (!values.containsKey(ZdrojakContract.VIEWED)) values.put(ZdrojakContract.VIEWED, false); values.put(ZdrojakContract.ADDED, new Date().getTime()); SQLiteDatabase db = openHelper.getWritableDatabase(); long id = db.insert(TB_NAME, null, values); if (id > 0) { Uri noteUri = ContentUris.withAppendedId( ZdrojakContract.CONTENT_URI_ARTICLES, id); return noteUri; } else { return null; } } private void checkMandatoryColumns(ContentValues values) { for (String key : MANDATORY_COLUMNS) { if (!values.containsKey(key)) { throw new NullPointerException( "ContentValues must contain key " + key); } } }
Update()
ani getType()
nic nového.
@Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { SQLiteDatabase db = openHelper.getWritableDatabase(); int count; switch (uriMatcher.match(uri)) { case ARTICLES: count = db.update(TB_NAME, values, selection, selectionArgs); break; case ARTICLE_ID: String[] newArgs = createSelectionArgsWithId(selectionArgs, uri .getPathSegments().get(1)); String where = createSelectionWithId(selection); count = db.update(TB_NAME, values, where, newArgs); break; default: throw new IllegalArgumentException("Unknown URI " + uri); } sendUpdateBroadcast(); return count; } @Override public String getType(Uri uri) { switch (uriMatcher.match(uri)) { case ARTICLES: return ZdrojakContract.MIME_TYPE_DIR; case ARTICLE_ID: return ZdrojakContract.MIME_TYPE_ITEM; default: throw new IllegalArgumentException("Unknown URI " + uri); } }
BulkInsert()
je zajímavější.
Metoda bulkInsert()
slouží ke vložení více dat najednou. Ve výchozí implmenentaci jen zavolá insert()
nkrát za sebou, ale my ještě odfiltrujeme staré články.
@Override public int bulkInsert(Uri uri, ContentValues[] values) { if (uriMatcher.match(uri) != ARTICLES) { throw new IllegalArgumentException("Unknown URI " + uri); } int insertCount = 0; long maxDate = getMaxDate(); for (ContentValues v : values) { checkMandatoryColumns(v); long date = v.getAsLong(ZdrojakContract.PUBDATE); if (date > maxDate) { if (insert(uri, v) != null) insertCount++; } } sendUpdateBroadcast(); return insertCount; } private long getMaxDate() { SQLiteDatabase db = openHelper.getReadableDatabase(); String[] cols = { ZdrojakContract.PUBDATE }; Cursor c = db.query(TB_NAME, cols, null, null, null, null, ZdrojakContract.SORT_ORDER_PUBDATE, "1"); if (!c.moveToFirst()) return -1; else return c.getLong(0); } private void sendUpdateBroadcast() { Intent i = new Intent(ZdrojakContract.ACTION_ZDROJAK_UPDATED); getContext().sendBroadcast(i); }
A nakonec onCreate()
a query()
. Tam také nic nového. (Pro metody createSelectionArgsWithId()
a createSelectionWithId
viz Vyvíjíme pro Android: Content providery
@Override public boolean onCreate() { openHelper = new DatabaseHelper(getContext()); return true; } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { SQLiteDatabase db = openHelper.getReadableDatabase(); if (sortOrder == null || sortOrder.equals("")) sortOrder = ZdrojakContract.DEFAULT_SORT_ORDER; switch (uriMatcher.match(uri)) { case ARTICLES: break; case ARTICLE_ID: selectionArgs = createSelectionArgsWithId(selectionArgs, uri .getPathSegments().get(1)); selection = createSelectionWithId(selection); break; default: throw new IllegalArgumentException("Unknown URI " + uri); } Cursor c = db.query(TB_NAME, projection, selection, selectionArgs, null, null, sortOrder); return c; }
V manifestu je ZdrojakProvider
zapsaný jednoduše. Možná by se slušelo přidat mu ještě writePermission
– číst může kdokoli, jsou to veřejně dostupná data, ale zapisovat by měli jen ti povolanější (a to pokud možno jen viewed
):
<provider android:name=".ZdrojakProvider" android:authorities="com.example.zdrojaknotifier.provider" />
ZdrojakActivity
Třídu ZdrojakActivity
byste měli bez problému pochopit. Upozorním na šikovnou metodu CursorAdapter.swapCursor()
.
public class ZdrojakActivity extends ListActivity { private BroadcastReceiver receiver; private ArticlesAdapter adapter = null; public static final String SHARED_PREFS_NAME = "sharedPrefs"; public static final String LAST_VIEWED = "lastViewed"; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Pro testování - Pozor na updatedToday = true, data se nemusí stáhnout! /* getContentResolver().delete(ZdrojakContract. CONTENT_URI_ARTICLES, "1", null); */ updateDataIfNone(); updateList(); setupDataUpdating(); IntentFilter filter = new IntentFilter(); filter.addAction(ZdrojakContract.ACTION_ZDROJAK_UPDATED); receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { updateList(); } }; registerReceiver(receiver, filter); } @Override public void onResume(){ super.onResume(); setLastViewedNow(); } @Override public void onDestroy() { unregisterReceiver(receiver); super.onDestroy(); } private void updateList() { Cursor c = getContentResolver().query( ZdrojakContract.CONTENT_URI_ARTICLES, ZdrojakContract.PROJECTION, null, null, ZdrojakContract.SORT_ORDER_PUBDATE); if (adapter == null) { adapter = new ArticlesAdapter(this, c, 0); setListAdapter(adapter); } else { adapter.swapCursor(c); } } @Override protected void onListItemClick(ListView l, View v, int position, long id) { String uriString = (String) v.getTag(R.id.tag_link); ZdrojakContract.setViewed(this, id, true); Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(uriString)); startActivity(i); } private void setLastViewedNow(){ getSharedPreferences(SHARED_PREFS_NAME, 0).edit() .putLong(LAST_VIEWED, new Date().getTime()).commit(); sendBroadcast(new Intent(ZdrojakContract.ACTION_LASTVIEW_CHANGED)); }
Alarm nastavíme půl hodiny po nejbližší půlnoci.
private void setupDataUpdating() { Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(System.currentTimeMillis()); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MINUTE, 30); calendar.set(Calendar.HOUR, 0); calendar.set(Calendar.AM_PM, Calendar.AM); calendar.add(Calendar.DAY_OF_MONTH, 1); Intent intent = new Intent(this, UpdateReceiver.class); intent.putExtra(UpdateReceiver.ALARM_BROADCAST, true); PendingIntent pi = PendingIntent.getBroadcast(this, 0, intent, 0); ((AlarmManager) getSystemService(Context.ALARM_SERVICE)).setRepeating( AlarmManager.RTC, calendar.getTimeInMillis(), AlarmManager.INTERVAL_DAY, pi); }
A ostatní metody už jsou zase jednoduché:
private void updateDataIfNone(){ Cursor c = getContentResolver().query( ZdrojakContract.CONTENT_URI_ARTICLES, ZdrojakContract.PROJECTION, null, null, ZdrojakContract.DEFAULT_SORT_ORDER); if(c.getCount() == 0){ Intent intent = new Intent(this, UpdateReceiver.class); sendBroadcast(intent); } } @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.main, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.menu_mark_all_read: ContentValues values = new ContentValues(); values.put(ZdrojakContract.VIEWED, true); getContentResolver().update(ZdrojakContract.CONTENT_URI_ARTICLES, values, "1", null); return true; default: return super.onOptionsItemSelected(item); } } }
V manifestu je ZdrojakActivity
zapsána klasicky.
ArticlesAdapter
ArticlesAdapter
zobrazí seznam článků včetně perexů. Přečtené budou mít černý titulek, nepřečtené zelený. Jak jste si určitě všimli ve ZdrojakActivity.onListItemClick()
odkaz na článek získávám z tagu příslušného View. (Mimochodem, id R.id.tag_link
jsem vytvořil v /res/values/ids.xml
.) Asi by bylo čistší poprosit Adapter o Cursor, ten přesunout na příslušnou pozici a link zjistit z něj, ale to je práce navíc.
Začátek ArticlesAdapter
-u je obyčejný:
public class ArticlesAdapter extends CursorAdapter { public ArticlesAdapter(Context context, Cursor c, int flags) { super(context, c, flags); } @Override public View newView(Context ctx, Cursor c, ViewGroup root) { LayoutInflater inflater = LayoutInflater.from(ctx); View view = inflater.inflate(R.layout.article, root, false); bindView(view, ctx, c); return view; } @Override public void bindView(View oldView, Context ctx, Cursor c) { int titleIndex = c.getColumnIndex(ZdrojakContract.TITLE); int descriptionIndex = c.getColumnIndex(ZdrojakContract.DESCRIPTION); int authorIndex = c.getColumnIndex(ZdrojakContract.AUTHOR); int pubdateIndex = c.getColumnIndex(ZdrojakContract.PUBDATE); int linkIndex = c.getColumnIndex(ZdrojakContract.LINK); int viewedIndex = c.getColumnIndex(ZdrojakContract.VIEWED);
Potom nastavím kořenovému View dané položky tag:
oldView.setTag(R.id.tag_link, c.getString(linkIndex));
A pak se postarám o titulek. Metodu Theme.resolveAttribute()
nebudete používat dvakrát často, takže případně vizte dokumentaci.
TextView title = (TextView) oldView.findViewById(R.id.title); title.setText(c.getString(titleIndex)); boolean viewed = c.getInt(viewedIndex) == 1; TypedValue tv = new TypedValue(); ctx.getTheme().resolveAttribute(android.R.attr.textColorPrimary, tv, true); title.setTextColor(ctx.getResources().getColor(viewed ? tv.resourceId : R.color.zdrojak_green));
Pak už se zase neděje nic zvláštního:
((TextView) oldView.findViewById(R.id.author)).setText(c .getString(authorIndex)); ((TextView) oldView.findViewById(R.id.description)).setText(c .getString(descriptionIndex)); long date = c.getLong(pubdateIndex); SimpleDateFormat f = new SimpleDateFormat("d. M."); ((TextView) oldView.findViewById(R.id.pubdate)).setText(f.format(new Date(date))); }
Soubor /res/layout/article.xml
vypadá takto:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".ZdrojakActivity" > <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_horizontal" android:textAppearance="?android:attr/textAppearanceLarge" android:textColor="?android:attr/textColorPrimary" android:textStyle="bold" /> <TextView android:id="@+id/pubdate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="right" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="?android:attr/textColorSecondary" /> <TextView android:id="@+id/author" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="left" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="?android:attr/textColorSecondary" /> <TextView android:id="@+id/description" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="left" android:layout_marginTop="8dp" android:textAppearance="?android:attr/textAppearanceMedium" android:textColor="?android:attr/textColorPrimary" /> </LinearLayout>
Není to nic hezkého, v příštím díle to možná napravíme.
Pokud vás zaujala namespace tools
, vězte, že to jsou pomocné atributy pro nový ADT plugin.
A zbývají nám už jen broadcast receivery.
UpdateReceiver
Začneme UpdateReceiver
-em. Ten je spouštěn jednak půl hodiny po půlnoci (s extra ALARM_BROADCAST = true
), jednak při každé změně stavu připojení a jednak občas ručně. Aby nevytvářel moc požadavků na Zdroják, do shared preferences uložíme informaci, zda byla data dnes už aktualizována ( UPDATED_TODAY
), jíž s každým spuštěním receiveru o půlnoci nastavíme na false
:
public class UpdateReceiver extends BroadcastReceiver { public static final String ALARM_BROADCAST = "alarmBroadcast"; public static final String UPDATED_TODAY = "updatedToday"; @Override public void onReceive(Context ctx, Intent intent) { SharedPreferences prefs = ctx.getSharedPreferences(ZdrojakActivity.SHARED_PREFS_NAME, 0); if(intent.getBooleanExtra(ALARM_BROADCAST, false)){ prefs.edit().putBoolean(UPDATED_TODAY, false).commit(); } if(!prefs.getBoolean(UPDATED_TODAY, false)) updateData(ctx, prefs); }
Když není k dispozici připojení, vyhodí se výjimka ZdrojakRSSHandlerException
. Toho využijeme a vůbec nemusíme kontrolovat, je-li telefon na internetu:
private void updateData(final Context ctx, final SharedPreferences prefs){ (new Thread(new Runnable() { public void run() { ZdrojakRSSHandler handler = new ZdrojakRSSHandler(ctx); try { handler.updateData(); prefs.edit().putBoolean(UPDATED_TODAY, true).commit(); } catch (ZdrojakRSSHandlerException e) { e.printStackTrace(); } catch (RSSParserException e) { e.printStackTrace(); } } })).start(); }
Broadcast receivery musejí být zapsané v manifestu:
<receiver android:name=".UpdateReceiver" > <intent-filter> <action android:name="android.net.conn.CONNECTIVITY_CHANGE" /> </intent-filter> </receiver>
Abychom mohli naslouchat Intentům s android.net.conn.CONNECTIVITY_CHANGE
, musíme mít permission ACCESS_NETWORK_STATE
.Zároveň s ním přidáme i permission INTERNET
:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
NotificationReceiver
Pokud jste si všimli, ZdrojakActivity
rozesílá broadcast, že se změnil čas jejího posledního zobrazení. Tomu (a k tomu ještě i broadcastu rozesílanému ZdrojakProvider
-em o změně dat) naslouchá NotificationReceiver
.
Ten vždycky na začátku odstraní případnou existující notifikaci (každá notifikace má své id), protože je opruz, když vidíte, že je nový článek, spustíte aplikaci pomocí seznamu aplikací, článek přečtete, ale notifikace zůstane.
public class NotificationReceiver extends BroadcastReceiver { private static final int NOTIFICATION_ID = 1; @Override public void onReceive(Context context, Intent incoming) { NotificationManager notificationManager = (NotificationManager) context .getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.cancel(NOTIFICATION_ID);
Potom zjistí, kdy byla ZdrojakActivity naposledy zobrazena a na základě toho vytvoří dva Cursory. V jednom budou články, které se stáhly až po posledním zobrazení Activity, ve druhém budou nepřečtené články:
long lastViewed = context.getSharedPreferences( ZdrojakActivity.SHARED_PREFS_NAME, 0).getLong( ZdrojakActivity.LAST_VIEWED, -1); ContentResolver cr = context.getContentResolver(); Cursor totalyNew = cr.query(ZdrojakContract.CONTENT_URI_ARTICLES, ZdrojakContract.PROJECTION, ZdrojakContract.ADDED + " > ?", new String[] { String.valueOf(lastViewed) }, ZdrojakContract.SORT_ORDER_PUBDATE); Cursor unread = cr.query(ZdrojakContract.CONTENT_URI_ARTICLES, new String[] { ZdrojakContract.TITLE }, ZdrojakContract.VIEWED + " = 0", null, ZdrojakContract.SORT_ORDER_PUBDATE);
A nakonec se podle počtu úplně nových rozhodne, jakou notifikaci zobrazí.
int unreadCount = unread.getCount(); Notification n; NotificationCompat.Builder builder = new NotificationCompat.Builder( context); Intent intent = new Intent(context, ZdrojakActivity.class); PendingIntent pi = PendingIntent.getActivity(context, 0, intent, 0);
Pokud je úplně nový jen jeden, bude notifikace obsahovat jeho titulek, jeho autora, na API >= 11 informaci o celkovém počtu nepřečtených článků (jako GMail) a na Jelly Bean roztahovací perex nového článku pomocí NotificationCompat.BigTextStyle
. Po kliknutí na notifikaci se spustí ZdrojakActivity.
if (totalyNew.getCount() == 1) { int titleIndex = totalyNew.getColumnIndex(ZdrojakContract.TITLE); int authorIndex = totalyNew.getColumnIndex(ZdrojakContract.AUTHOR); int descIndex = totalyNew .getColumnIndex(ZdrojakContract.DESCRIPTION); totalyNew.moveToFirst(); n = builder .setContentTitle(totalyNew.getString(titleIndex)) .setContentText(totalyNew.getString(authorIndex)) .setSmallIcon(R.drawable.notification) .setContentIntent(pi) .setNumber(unreadCount) .setStyle( new NotificationCompat.BigTextStyle() .bigText(totalyNew.getString(descIndex))) .build(); }
Pokud je úplně nových článků více, bude v titulku notifikace, že jsou dostupné nové články, v malém textu i jejich počet, ten bude také vpravo dole. A na Jelly Bean po vzoru GMailu nabídneme seznam (pěti, více nejde) titulků pomocí NotificationCompat.InboxStyle.html
:
else if (totalyNew.getCount() > 1) { Resources res = context.getResources(); int titleIndex = unread.getColumnIndex(ZdrojakContract.TITLE); NotificationCompat.InboxStyle st = new NotificationCompat.InboxStyle(); if(unread.getCount() > 5) st.setSummaryText(res.getString(R.string.and_x_more, unread.getCount() - 5)); for(int i = 0;i < 5 && unread.moveToNext();i++){ st.addLine(unread.getString(titleIndex)); } n = builder .setContentTitle(res.getString(R.string.new_articles)) .setContentText( res.getString(R.string.new_articles_count, unreadCount)) .setSmallIcon(R.drawable.notification) .setContentIntent(pi) .setNumber(unreadCount) .setStyle(st) .build(); }
Pokud nejsou žádné úplně nové články, notifikace nebude:
else{ return; }
No a nakonec vyrobenou notifikaci zobrazíme:
notificationManager.notify(NOTIFICATION_ID, n);
Nové články na Zdrojáku v akci!

Zmenšená notifikace na Jelly Bean (zaboha se mi ji nepodařilo zmenšit na emulátoru, takže je screnshot z mého Nexusu S)
Notifikační ikony
Protože mám ještě nějaké místo k dobru, zmíním se o notifikačních ikonách.
Od Androidu 3.0 mají být notifikační ikony čtvercové a mají se skládat jen z bílé a průhlednosti. Podle icon guidelines mají mít na ldpi displejích rozměry 18 × 18 px
, na mdpi 24 × 24 px
, na hdpi 36 × 36 px
a na xhdpi 48 × 48 px
.
Na Androidu 2.3 sice už mají být víceméně průhledné, ale mají být v odstínech šedi a jinak velké.
A aby toho nebylo málo, na Androidu < 2.3 mají být ikony zase čtverečkové, ačkoli s trochu jinými rozměry a nemají mít průhledné pozadí (jen malinko v rozích).
Pokud chcete mít aplikaci, jejíž notifikace vypadají všude použitelně, musíte vytvořit složky drawable-xhdpi-v11
, drawable-hdpi-v11
, drawable-mdpi-v11
, a drawable-ldpi-v11
s ikonou pro Android >= 3.0; složky drawable-xhdpi-v9
, drawable-hdpi-v9
, drawable-mdpi-v9
, a drawable-ldpi-v9
s ikonou pro Android 2.3 a složky drawable-xhdpi
, drawable-hdpi
, drawable-mdpi
, a drawable-ldpi
pro Android < 2.3. Soubor ikony se samozřejmě musí jmenovat vždy stejně.
Pro dnešní ukázku jsem opravdu tolik ikon nevytvářel, ale pokud bude čas, pokusím se to příště napravit.
Závěr
Dnes jsme si na aplikaci, která má s přimhouřením oka opravdu praktické využití, vyzkoušeli práci s broadcast receivery, notifikacemi, naučili jsme se pracovat s Internetem, AlarmManagerem a procvičili jsme si plno dalších věcí. Dovolím si konstatovat, že pokud umíte všechno, co jsme používali v tomto a předchozích dílech, umíte toho mnohem víc než já.
Zdrojové kódy dnešní aplikace si můžete stáhnout na této adrese.
V příštím díle zkusíme dnešní aplikaci trochu zkultivovat a nahrát ji na Play Store.
Tip na konec
Stiskem klávesy F8
na emulátoru vypnete/zapnete připojení k internetu. Jde tak docela pěkně testovat broadcast receiver.
Naopak jsem nikde nenašel, jak testovat AlarmManager, takže to dělám tak, že zavolání nastavím na blízkou budoucnost. Ale ani to není tak jednoduché, Calendar se občas chová zvláštně. Pro vypsání data z kalendáře můžete použít následující útržek kódu. Sice používá deprecated metodu, ale to nám nevadí.
Log.d("calendar", calendar.getTime().toLocaleString());
„Nikdy byste neměli z nějakého procesu běžícího na pozadí (ať už broadcast receiveru, service nebo čehokoli jiného) přímo spouštět nějakou Activity. To by uživatele pěkně naštvalo. Místo toho vyrobte notifikaci, on se o všem dozví a Activity spustí, až bude sám chtít (nebo nespustí, když chtít nebude).“
Tohle je samozrejme pravidlo, ktery ma spousty vyjimek. Jeden priklad je treba Google Chrome to Phone ( https://play.google.com/store/apps/details?id=com.google.android.apps.chrometophone ), ktera primo z broadcast receiveru spousti aktivitu browseru.
Dalsi priklady sou treba ruzny pop-up okna pro SMS atd. ( https://play.google.com/store/apps/details?id=net.everythingandroid.smspopup )
Proto nikdy nerikej nikdy ;)
Aplikace mi nestahuje žádné články ani po reinstalaci, a to ještě před pár dny fungovala skvěle! Může to být tou „vylepšenou“ verzí zdrojáku, tím pádem asi i jinou strukturou RSS?
Mohlo by to být tím. RSS jsou nyní na jiných URL, ale původní URL na ně přesměrovávají. Zeptáme se Matěje, zda nebude třeba adresy v aplikaci upravit.
Poradíte mi prosím jak přehrát zvuk?
Dobrý den, po spuštění mi aplikace padá, nevíte co s tím? Jedná se o tu verzi, která je ke stažení na konci článku. Díky :)