Nový projekt

Začíname s novým projektom, ktorý bude obsahovať jednu prázdnu aktivitu (empty views activity).

Upozornenie: Odporúčam mať aktualizované Android Studio a AGP (Android Gradle plugin). Tento codelab bol pripravený s použitím Android Studio 2023.2.1 (Iguana) a Android Gradle plugin 8.3.1. Od verzie AGP 8.3 (ktorá sa nedá použiť v starších verziach Android Studio) sú tam zmeny v spôsobe ako sa pracuje so závislosťami (dependencies). V tomto codelabe bude spomenuté ako to zapísať aj v prípade, že použijete staršie verzie.

Cieľom aplikácie bude zobraziť zoznam hovorov (prijaté, neprijaté, odchádzajúce). Vytvorme triedu Call, ktorá bude reprezentovaná troma údajmi (telefónne číslo, typ hovoru a dátum). Ak budeme vytvárať objekty manuálne, postačuje zadať v konštruktore telefónne číslo, zvyšné parametre majú priradené default hodnoty.

Call.kt

data class Call (
    val number: String,
    val type: Int = 0,
    val date: String = ""
)

Content provider

Doteraz sme videli prácu s lokálnymi údajmi iba v rámci jednej aplikácie (údaje boli uložené v SQLite databáze a pristupovalo sa k nic pomocou Room knižnice), prípadne boli dáta dostupné cez REST API. Android poskytuje spôsob ako zdieľať dáta medzi aplikáciami (resp. medzi procesmi). Tento spôsob sa nazýva content provider.

Android poskytuje niektoré svoje údaje z telefónu pomocou príslušných poskytovateľov obsahu (viď zoznam). Napríklad existuje spôsob ako získať, prípadne modifikovať kontakty, informácie v kalendári, súbory médii a pod.

Z vyššie uvedeného zoznamu nás bude zaujímať trieda CallLog.Calls (dokumentácia), ktorá obsahuje hodnoty NUMBER, TYPE a DATA (odporúčam pozrieť v dokumentácii, čo reprezentujú). Tieto premenné iba označujú atribúty (názvy stĺpcov) z tabuľky hovorov, ktorá bude poskytnutá. Spôsob ako k týmto dátam pristupovať budeme vidieť v nasledovných krokoch.

Vytvoríme ViewModel, ktorý poskytuje dáta pre aktivitu. Zatiaľ tam bude fixný zoznam telefonátov. Na konci tejto aplikácie budeme získavať tento zoznam z nášho telefónu.

CallViewModel.kt

import android.provider.CallLog
import androidx.lifecycle.ViewModel

class CallViewModel : ViewModel() {

    val calls = listOf(
        Call("0901234567"),
        Call("0907888333"),
        Call("0910405405"),
        Call("112", type = CallLog.Calls.MISSED_TYPE)
    )

}

Následne vytvoríme adaptér, ktorý bude využívať layout pre jednu položku už definovaný v androide (android.R.id.text1). Rozšírením triedy ListAdapter dostávame interný zoznam, ktorý je potom zobrazovaný v RecyclerView.

CallLogAdapter.kt

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView

class CallLogAdapter : ListAdapter<Call, CallLogAdapter.CallViewHolder>(DiffCallback) {

    class CallViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val textView: TextView = itemView.findViewById(android.R.id.text1)

        fun bind(item: Call) {
            textView.text = item.number
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CallViewHolder {
        val layout = LayoutInflater.from(parent.context)
            .inflate(android.R.layout.simple_list_item_1, parent, false)
        return CallViewHolder(layout)
    }

    override fun onBindViewHolder(holder: CallViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    object DiffCallback : DiffUtil.ItemCallback<Call>() {
        override fun areItemsTheSame(oldItem: Call, newItem: Call) =
            oldItem.number == newItem.number

        override fun areContentsTheSame(oldItem: Call, newItem: Call) =
            oldItem == newItem
        }

}

Ak chceme, môžeme pridať farebné zvýraznenie položiek podľa typu hovoru.

CallViewHolder (CallListAdapter.kt)

fun bind(item: Call) {
       textView.text = item.number
       if (item.type == CallLog.Calls.MISSED_TYPE) {
                itemView.setBackgroundColor(Color.RED)
       }
    ...
}

V tejto aplikácii budeme pridávať závislosti len v momente, keď budú potrebné. Na získanie referencie na ViewModel v aktivite , resp. vo fragmente (fragmenty uvidíme a vysvetlíme v ďalšom kroku), využívame delegáta by viewModels(). Pripomínam, že v Kotline môžeme delegovať gettre a settre nejakej premennej na niekoho iného. V tomto prípade je implementácia delegáta dostupná v Android KTX.

V našom prípade budeme vyžadovať dve závislosti. V staršej verzii Android Gradle plugin a Android Studio (viď popis pri prvom kroku v tomto codelabe) by sme to zapísali takto:

build.gradle.kts (Module :app)

dependencies {
    ...
    implementation("androidx.activity:activity-ktx:1.8.2")
    implementation("androidx.fragment:fragment-ktx:1.6.2")
   
}

Gradle version catalogs

Potrebné závislosti zapíšeme nasledovne:

build.gradle.kts (Module :app)

dependencies {
    implementation(libs.androidx.fragment.ktx)
    implementation(libs.androidx.activity.ktx)
    ...
}

libs.versions.toml (Gradle Scripts)

[versions]
...
fragment-ktx = "1.6.2"
activity-ktx = "1.8.2"

[libraries]
...
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment-ktx" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" }

...

Okrem toho je potrebné zapnúť viewBinding na automatické prepojenie widgetov deklarovaných v xml súboroch layoutu s kódom v aktivite (resp. fragmente).

build.gradle.kts (Module :app)

android {
    ...
    buildFeatures {
        viewBinding true
    }        
}

Nastavovanie závislostí je možné vykonať aj cez Project Structure - nájdeme to v hlavnom menu (File->Project Structure).

Fragmenty, ktoré budeme používať, sa dajú aj vygenerovať. Tentokrát to nebudeme robiť - je tam dosť pre nás zbytočného kódu. Odporúčam si to ale pozrieť ako to vyzerá.

Fragment

Odporúčam pozrieť do dokumentácie - Fragmenty v androide (okrem iného je tam obrázok ako využiť fragmenty pri rôznej veľkosti obrazovky).

A Fragment represents a reusable portion of your app's UI. A fragment defines and manages its own layout, has its own lifecycle, and can handle its own input events. Fragments can't live on their own. They must be hosted by an activity or another fragment.

V našej aplikácii vytvoríme dva fragmenty, ktoré budú hosťované aktivitou MainActivity - kód pre túto aktivitu nebudeme nijak upravovať (iba sa upraví príslušný layout).

Vytvoríme si nový XML súbor pre layout fragmentu:

/res/layout/fragment_master.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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"
    tools:context=".MasterFragment">

    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/recyclerview1"/>

</FrameLayout>

FrameLayout je jednoduchý kontainer vhodný na použitie predovšetkým ak sa v layoute nachádza jediný widget.

Fragmenty sa hosťujú v nejakej aktivite a bez nej nevedia fungovať. Pri fragmentoch je možné používať aj konštruktory. Avšak často sa dá fungovať s prekrytím dvoch funkcií onCreateView a onViewCreated. Jedna určuje ako sa má vykresliť vizuál a druhá, čo sa má stať po vykreslení. Neskôr si ukážeme jednoduchšie použitie. Avšak ak chceme využiť viewBinding, potrebujeme prekryť obe tieto metódy.

MasterFragment.kt

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import sk.upjs.calllog.databinding.FragmentMasterBinding

class MasterFragment : Fragment() {

    private lateinit var binding: FragmentMasterBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentMasterBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val adapter = CallLogAdapter()
        binding.recyclerview1.layoutManager = LinearLayoutManager(requireContext())
        binding.recyclerview1.adapter = adapter
        val viewModel: CallViewModel by viewModels()
        adapter.submitList(viewModel.calls)
    }
}

Použitím requireContext získame context (v našom prípade je contextom aktivita). Existuje aj metóda getContext, resp. property context, avšak pri použití requireContext nemôžeme dostať null.

V layoute aktivity pridáme fragment použitím widgetu FragmentContainerView, kde špecifikujeme triedu, ktorá zodpovedá fragmentu.

/res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/master_fragment"
        android:name="sk.upjs.calllog.MasterFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"

        />

</androidx.constraintlayout.widget.ConstraintLayout>

Aplikáciu si môžeme spustiť.

Druhý fragment bude zobrazovať detailnejšie informácie o jednotlivých hovoroch. Na začiatok si ale vyskúšame iba zobraziť druhý fragment bez obsahu (t.j. necháme tam ľubovoľný text).

Vytvorenie triedy fragmentu je jednoduché. V konštruktore označíme príslušný layout:

DetailFragment.kt

import androidx.fragment.app.Fragment

class DetailFragment : Fragment(R.layout.fragment_detail)

/res/layout/fragment_detail.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/detail_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"


        android:text="HELLO"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Pre nastavenie layoutu len pre určitú konfiguráciu:

  1. Klikneme na priečinok res/layout -> New -> Layout resource file
  2. File name: activity_main (resp. tak ako sa volá layout pre hlavnú aktivitu)
  3. ostatné nastavenia nezmenené
  4. z ponuky Available qualifiers vyberieme Orientation a zvolíme landscape
  5. layout bude obsahovať dva fragmenty

/res/layout-land/activity-main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/master_fragment"
        android:name="sk.upjs.calllog.MasterFragment"

        android:layout_height="match_parent"
        android:layout_width="0dp"
        app:layout_constraintWidth_default="percent"
        app:layout_constraintWidth_percent="0.4"

        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/detail_fragment"
        app:layout_constraintTop_toTopOf="parent"  />

    <androidx.fragment.app.FragmentContainerView

        android:id="@+id/detail_fragment"
        android:name="sk.upjs.calllog.DetailFragment"

        android:layout_height="match_parent"
        android:layout_width="0dp"
        app:layout_constraintWidth_default="percent"
        app:layout_constraintWidth_percent="0.6"

        app:layout_constraintStart_toEndOf="@id/master_fragment"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Všimnite si v kóde ako je riešené rozvrhnutie šírky 40% vs 60%. Môžete sa pozrieť aj na ostatné nastavenia pre ConstraintLayout.

Aplikáciu môžeme spustiť. V landscape režime by malo byť vidieť recycler view v ľavej časti. Klikanie zatiať nefunguje.

To, že máme jeden layout pre rôzne konfigurácie implikuje nasledovné veci:

  1. Rovnako môžeme mať rôzne verzie strings.xml pre rôzne jazykové verzie.
  2. Pri zmene konfigurácie je nutné načítať nanovo resources. Teda ak sa otočí obrazovka, tak sa aktivita reštartuje (spomeniete si na prvú aplikáciu, kde sme stav hry uložili v Bundle).

Vytvoríme ďalší ViewModel, v ktorom si budú fragmenty zdieľať informáciu o aktuálne vybranom telefonáte. Obidva fragmenty sú prevádzkované rovnakou aktivitou a preto vedia mať prístup k spoločným dátam.

Použitie ViewModel je bežné pre komunikáciu medzi fragmentmi. Nastáva situácia, že aplikácia má viac ako jeden ViewModel. V našom prípade jeden ViewModel slúži ako úložisko zoznamu hovorov, resp. miesto odkiaľ sa volajú korutíny na získanie týchto informácii. Druhý ViewModel slúži na komunikáciu medzi fragmentmi.

Schématický náčrt komunikácie:

Detailnejšie vysvetlenie lambda výrazov je v predošlom Codelabe (aplikácia opendatagame).

SharedViewModel.kt

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class SharedViewModel : ViewModel() {

    val selectedCall = MutableLiveData<Call>()

    fun select(call: Call) {
        selectedCall.value = call
    }
}

MutableLiveData je také rozšírenie triedy LiveData, že má prístupné funkcie setValue a postValue. Funkciu postValue je vhodné použiť ak nastavujeme hodnotu z iného ako hlavného vlákna (na pozadí). V tomto prípade voláme funkciu setValue - v Kotline využívame volanie settra priamo cez nastavenie hodnoty property value.

DetailFragment.kt

import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels

class DetailFragment : Fragment(R.layout.fragment_detail) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val textView: TextView = view.findViewById(R.id.detail_text_view)
        val sharedViewModel : SharedViewModel by activityViewModels()
        sharedViewModel.selectedCall.observe(viewLifecycleOwner) {
            textView.text = it.toString()
        }
    }
}

Komentáre ku kódu pre DetailFragment:

CallLogAdapter.kt

...

class CallLogAdapter(private val onClick: (Call) -> Unit) :
    ListAdapter<Call, CallLogAdapter.CallViewHolder>(DiffCallback) {

    class CallViewHolder(
        itemView: View,
        private val onClick: (Call) -> Unit
    ) : RecyclerView.ViewHolder(itemView) {
        
        ...

        fun bind(item: Call) {
            ...
            textView.setOnClickListener {
                onClick(item)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CallViewHolder {
        val layout = LayoutInflater.from(parent.context)
            .inflate(android.R.layout.simple_list_item_1, parent, false)
        return CallViewHolder(layout, onClick)
    }
        
    ...
}

Adaptér upravíme, aby reagoval na kliknutie na jeden telefonát spustením funkcie onClick, ktorej lambda výraz (literál funkcie) implementujeme v hlavnom fragmente. Definuje sa iba typ premennej. V tomto prípade ide o funkciu, ktorá má nejaké vstupy a výstup (Call -> Unit). Táto funkcia referencovaná premennou onClick sa dá posunúť ako parameter inej funkcie alebo konštruktora (vidíme vo funkcii onCreateViewHolder) alebo sa dá zavolať (vidíme vo funkcii bind po zaznamenaní kliknutia na TextView).

MasterFragment.kt

...
import androidx.fragment.app.activityViewModels

class MasterFragment : Fragment() {

	...
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val sharedViewModel: SharedViewModel by activityViewModels()
        val adapter = CallLogAdapter {
            sharedViewModel.select(it)
        }
        ...
    }
}

MasterFragment implementuje funkciu (pomocou lambda výrazu), ktorou nastavuje aktuálny Call v zdieľanom View Modeli.

Aplikáciu si môžeme spustiť. DetailFragment je zatiaľ zobrazený iba v landscape režime. Klikanie teda funguje iba keď je telefón otočený na šírku.

Navigation označuje interakcie, ktorými sa používateľ dostane na jednotlivé časti aplikácie. V našom prípade sa budeme pohybovať medzi jednotlivými fragmentmi. Tento koncept pozostáva z troch základnych zložiek:

libs.versions.toml

[versions]
...
navigation = "2.7.7"

[libraries]
...
navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }

build.gradle.kts (Module :app)

...
dependencies {

    implementation(libs.navigation.fragment.ktx)
    implementation(libs.navigation.ui.ktx)
    ...

}

Okrem toho je možné použiť aj Safe Args - je to odporúčane, ale pri Jetpack Compose sa to nepoužíva, takže to nemusíme skúšať.

Vytvoríme v resources New Resources File:

/res/navigation/navigation.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/navigation"
    app:startDestination="@id/master_fragment">

    <fragment
        android:id="@+id/master_fragment"
        android:name="sk.upjs.calllog.MasterFragment"
        android:label="fragment_master">
        <action
            android:id="@+id/action_from_master_to_detail"
            app:destination="@id/detail_fragment" />
    </fragment>


    <fragment
        android:id="@+id/detail_fragment"
        android:name="sk.upjs.calllog.DetailFragment"
        android:label="fragment_detail">
        <action
            android:id="@+id/action_from_detail_to_master"
            app:destination="@id/master_fragment" />
    </fragment>

</navigation>

Všimnite si pri navigation.xml aj vizuálne zobrazenie grafu, nie iba xml kód.

/res/layout/activity_main.xml (ten pôvodný)

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/hosting_fragment_container"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:navGraph="@navigation/navigation" />

</androidx.constraintlayout.widget.ConstraintLayout>

Aktivita zobrazuje landscape layout na šírku, kde zobrazí oba fragmenty vedľa seba. V opačnom prípade (portrait režim) sa použije layout, kde je vložený jeden kontainer na fragment. V tomto prípade je to príslušný NavHostFragment. Nahradíme názov nášho MasterFragment označením androidx.navigation.fragment.NavHostFragment.

MasterFragment.kt

...
import androidx.navigation.findNavController

class MasterFragment : Fragment() {

	...
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
        val adapter = CallLogAdapter {
            sharedViewModel.select(it)
            val container: View? = activity?.findViewById(R.id.hosting_fragment_container)
            if (container != null) {
                view.findNavController().navigate(R.id.action_from_master_to_detail)
            }
        }
        ...
    }
}


MasterFragment je upravený tak, aby po kliknutí na jednu položku v RecyclerView boli aktualizované LiveData v zdieľanom ViewModel a zároveň, aby sa prepol fragment na obrazovke na ten, kde je zobrazenie detailov o jednom telefonáte.

Podmienka if (container != null) slúži na rozlíšenie natočenia zariadenia. Pri landscape režime widget s príslušným id neexistuje. Pri portrait režime existuje, a preto voláme kontroler, aby uskutočnil navigačnú cestu reprezentovanú akciou - hranou v grafe.

DetailFragment.kt

...
import androidx.navigation.findNavController

class DetailFragment : Fragment(R.layout.fragment_detail) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
        val container: View? = activity?.findViewById(R.id.hosting_fragment_container)
        if (container != null) {
            textView.setOnClickListener {
                view.findNavController().navigate(R.id.action_from_detail_to_master)
            }
        }
    }
}

DetailFragment sa prehodí na MasterFragment po kliknutí na TextView.

Content Provider je v androide komponent, ktorý poskytuje rozhranie na prístup k dátam aj z iných aplikácii. Content resolver je spôsob ako k týmto dátam pristupovať a vykonávať operácie (CRUD).

CallViewModel.kt

...
import android.app.Application
import androidx.lifecycle.AndroidViewModel

class CallViewModel(application: Application) : AndroidViewModel(application) {

    ...

    private val contentResolver by lazy {
        application.contentResolver
    }
}

Triedu CallViewModel zmeníme, aby rozširovala triedu AndroidViewModel. Získame tak prístup k application kontextu, pomocou ktorého máme dostupný contentResolver.

Vytvoríme premennú contentResolver na prístup k dátam. Použijeme delegát by lazy, čo znamená, že sa hodnota priradí do premennej až v momente prvého zavolania getter metódy.

Následne načítame dáta z tabuľky. Jednotlivé položky (stĺpce z databázovej tabuľky) sú popísané v dokumentácii triedy CallLog.Calls. Content resolver query má viacero parametrov, ktoré v tomto prípade nepoužijeme.

CallViewModel.kt

...
import android.provider.CallLog
import java.text.SimpleDateFormat
import java.util.Locale

class CallViewModel(application: Application) : AndroidViewModel(application) {

    ...

    fun loadCalls(): List<Call> {
        val calls = mutableListOf<Call>()

        val cursor = contentResolver.query(
            CallLog.Calls.CONTENT_URI, null, null,
            null, null, null
        ) ?: return emptyList()

        if (cursor.count > 0) {
            val numberColIdx = cursor.getColumnIndex(CallLog.Calls.NUMBER)
            val typeColIdx = cursor.getColumnIndex(CallLog.Calls.TYPE)
            val dateColIdx = cursor.getColumnIndex(CallLog.Calls.DATE)
            val simpleDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale.ENGLISH)
            while (cursor.moveToNext()) {
                val number = cursor.getString(numberColIdx)
                val type = cursor.getInt(typeColIdx)
                val dateLong = cursor.getLong(dateColIdx)
                val date = simpleDateFormat.format(dateLong)
                calls.add(Call(number, type, date))
            }
        }
        cursor.close()
        return calls
    }

}

Komentár k čítaniu dát pomocou ContentResolver:

V triede CallsViewModel vytvoríme vlastné LiveData. ContentResolver poskytuje observer. Pri zmene dát v databázovej tabuľke sa aktualizujú LiveData používané v našej aplikácii.

Pri použití databázy je toto robené automaticky cez knižnicu Room (spomeňte si na volanie toLiveData()).

CallViewModel.kt

...
import android.database.ContentObserver
import androidx.lifecycle.LiveData

class CallViewModel(application: Application) : AndroidViewModel(application) {

    val calls = object : LiveData<List<Call>>() {
        lateinit var observer: ContentObserver

        override fun onActive() {
            observer = object : ContentObserver(null) {

                override fun onChange(selfChange: Boolean) {
                    value = loadCalls()
                }

            }
            contentResolver.registerContentObserver(CallLog.Calls.CONTENT_URI, true, observer)
            observer.onChange(true)
        }

        override fun onInactive() {
            contentResolver.unregisterContentObserver(observer)
        }
    }

	...

}

MasterFragment.kt

...
class MasterFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
        val viewModel: CallViewModel by viewModels()
        viewModel.calls.observe(viewLifecycleOwner) {
            adapter.submitList(it)
        }


    }
}

Permissions on Android

Aktuálna aplikácia spadne s FATAL EXCEPTION java.lang.SecurityException: Permission Denial: opening provider com.android.providers.contacts.CallLogProvider.

V manifest.xml pridáme permission na prístup k histórii hovorov READ_CALL_LOG.

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.READ_CALL_LOG" />

    ...

</manifest>

V túto chvíľu vieme používať aplikáciu ak v nastaveniach telefónu priradíme príslušné povolenie (v sekcii aplikácie, povolenia a pod.).

Vyžiadanie povolenia od používateľa

V MasterFragment presunieme všetok kód z metódy onViewCreated do samostatnej metódy init. Tento kód sa zavolá, len ak aplikácia bude mať od používateľa udelené povolenie.

MasterFragment.kt

...
class MasterFragment : Fragment() {

    ...
    private fun init(){ 
        val sharedViewModel: SharedViewModel by activityViewModels()
            val adapter = CallLogAdapter {
                sharedViewModel.select(it)
                val container: View? = activity?.findViewById(R.id.hosting_fragment_container)
                if (container != null) {
                    view?.findNavController()?.navigate(R.id.action_from_master_to_detail)
                }
            }
            binding.recyclerview1.layoutManager = LinearLayoutManager(requireContext())
            binding.recyclerview1.adapter = adapter
            val viewModel: CallViewModel by viewModels()
            viewModel.calls.observe(viewLifecycleOwner) {
                adapter.submitList(it)
            }
    }
}

Následný spôsob získavania permission je popísaný v dokumentácii vrátane diagramov. V princípe je potrebné pred prácou s kódom, ktorý závisí na nejakom povolení, najprv si to povolenie vyžiadať. Ak bolo permission zamietnuté zo strany používateľa, zvykne sa pri opätovnom vyžiadaní si povolenia poskytnúť viac informácii (viď showRequestPermissionRationale).

MasterFragment.kt

...
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat

class MasterFragment : Fragment() {

  	...
    
    private val requestPermissionLauncher =
        registerForActivityResult(
            ActivityResultContracts.RequestPermission()
        ) { isGranted: Boolean ->
            if (isGranted) {
                init()
            } else {
                Toast.makeText(requireContext(), "PERMISSION MISSING", Toast.LENGTH_SHORT).show()
            }
        }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        when {
            ContextCompat.checkSelfPermission(
                requireContext(), android.Manifest.permission.READ_CALL_LOG
            ) == PackageManager.PERMISSION_GRANTED -> {
                // mam povolenie
                init()
            }

            shouldShowRequestPermissionRationale(android.Manifest.permission.READ_CALL_LOG) -> {
                // tu bude alert dialog
                Toast.makeText(requireContext(), "NO PERMISSION", Toast.LENGTH_SHORT).show()
            }

            else -> {
                requestPermissionLauncher.launch(android.Manifest.permission.READ_CALL_LOG)
            }
        }
    }

    private fun init() {
        ...
    }
}

Aplikáciu si môžeme spustiť a sledovať správanie.

Funguje nasledovne:

Gitlab kód

Pri vytváraní vlastných aplikácii odporúčam pozrieť v dokumentácii na prácu s fragmentmi, navigation a permissions.

Často je vhodná vec na použitie dialog fragment.