Na začiatok si vytvoríme nový projekt s jednou prázdnou aktivitou (empty activity).

Základnou entitou bude informácia o jednom telefonáte. Vytvoríme triedu Call, ktorá bude uchovávať nejakú časť z informácii dostupných v androide (CallLog.Calls). Vytvoríme tiež ViewModel, ktorý bude (zatiaľ) poskytovať fixný zoznam telefonátov. Ten neskôr vytiahneme z androidu.

Call.kt

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

CallViewModel.kt

import androidx.lifecycle.ViewModel

class CallViewModel : ViewModel() {

    val calls = listOf(
        Call("0901234567"),
        Call("112"),
        Call("09078883332"),
        Call("0910405405")
    )

}

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.OUTGOING_TYPE) {
             itemView.setBackgroundColor(Color.RED)
       }
    ...
}

Fragmenty v androide

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á.

Fragmenty musia byť prevádzkované aktivitou. V našej aplikácii vytvoríme dva fragmenty, ktoré budú v rámci MainActivity - kód pre túto aktivitu nie je potrebné upravovať.

/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>

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í.

MasterFragment.kt

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

class MasterFragment : Fragment() {

    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.

ViewBinding je potrebné aktivovať v build.gradle. Aby bolo možné získať viewModel vo fragmente, je potrebné doplniť dependency (s príslušnou verziou knižníc).

build.gradle (module)

android {
    ...
    buildFeatures {
        viewBinding true
    }        
}

dependencies {
    ...
    implementation 'androidx.activity:activity-ktx:1.6.1'
    implementation 'androidx.fragment:fragment-ktx:1.5.5'
   
}

DetailFragment.kt

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup

class DetailFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
         return inflater.inflate(R.layout.fragment_detail, container, false)
    }
}

/res/layout/fragment_detail.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=".DetailFragment">

    <TextView
        android:id = "@+id/detail_text_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@string/hello_blank_fragment" />

</FrameLayout>

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"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fragment
        class="sk.itsovy.android.calllog.MasterFragment"
        android:id = "@+id/master_fragment"
        android:layout_height = "match_parent"
        android:layout_width = "0dp"
        android:layout_weight = "1"
        />

    <fragment
        class="sk.itsovy.android.calllog.DetailFragment"
        android:id = "@+id/detail_fragment"
        android:layout_height = "match_parent"
        android:layout_width = "0dp"
        android:layout_weight = "2"
        />

</LinearLayout>

Widget fragment môže byť nahradený androidx.fragment.app.FragmentContainerView.

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

Dve poznámky k layoutom:

  1. LinearLayout a FrameLayout sú alternatívy k nášmu často používanému ConstraintLayout. V súčasnosti sa používajú len pri jednoduchších prípadoch FrameLayout ak je tam len jeden widget a LinearLayout, keď sú položky vedľa seba, resp. pod sebou.
  2. 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.

SharedViewModel.kt

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

class SharedViewModel : ViewModel() {

    val selectedCall = MutableLiveData<Call>()

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

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.

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) {
        private val textView: TextView = itemView.findViewById(android.R.id.text1)

        fun bind(item: Call) {
            textView.text = item.number
            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)
    }

...

Oba fragmenty použijú vybraný telefonát - MasterFragment nastaví hodnotu a DetailFragment bude sledovať zmeny tejto hodnoty.

DetailFragment.kt

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()
        }
}

MasterFragment.kt

 val adapter = CallLogAdapter{
            sharedViewModel.select(it)
}

V DetailFragment sa ViewModel získava pomocou activityViewModels namiesto viewModels. Dôvod uvidíme neskôr - budeme potrebovať ActivityViewModel kvôli dostupnosti referencie na objekt triedy Application.

Pri funkcii observe na live dátach potrebujeme dať ako parameter viewLifecycleOwner, čím prenesieme zodpovednosť na android, aby nám dodal vlastníka životného cyklu - on sa dopracuje k príslušnej aktivite.

Ak je v nejakej funkcii ako posledný parameter iná funkcia - teda na posledné miesto ide lambda výraz, tak tú funkciu môžeme zapísať aj tak, že lambdu píšeme do {} za volanie metódy. Teda namiesto funkcia(premenná, lambda) urobíme funkcia(premenná){lambda} - viď metóda observe vo fragmente.

Navigation component

build.gradle

plugins {
    ...
    id 'androidx.navigation.safeargs'
}

dependencies {

    def nav_version = "2.3.5"
    implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
    implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
    ...
}

build.gradle (ten v projekte nie module)

buildscript {
    repositories {
        google()
    }
    dependencies {
        classpath 'androidx.navigation.safeargs.kotlin:androidx.navigation.safeargs.kotlin.gradle.plugin:2.5.3'
    }
}
...

V build.gradle musí ísť buildscript pred sekciou plugins.

Vytvoríme v resources New -> Android Resource Directory a vyberieme typ navigation. V príslušnom novom súbore bude popis prechodov medzi fragmentami.

/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"
    xmlns:tools="http://schemas.android.com/tools"
    app:startDestination="@id/master_fragment"
    android:id="@+id/navigation">

    <fragment
        android:id = "@+id/master_fragment"
        android:name = "sk.upjs.vma.calllog.MasterFragment"
        android:label = "fragment_master"
        tools:layout = "@layout/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.vma.calllog.DetailFragment"
        android:label = "fragment_detail"
        tools:layout = "@layout/fragment_detail"
        >
        <action
            android:id = "@+id/action_from_detail_to_master"
            app:destination = "@id/master_fragment"
            />
    </fragment>
</navigation>

/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:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

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

</androidx.constraintlayout.widget.ConstraintLayout>

MasterFragment.kt

import androidx.navigation.findNavController

...
		val adapter = CallLogAdapter{
            sharedViewModel.select(it)
            // zistit ci som na vysku (portrait)
            val container: View? = activity?.findViewById(R.id.nav_host_fragment)
            if (container != null) {
                view.findNavController().navigate(MasterFragmentDirections.actionFromMasterToDetail())
            }
        }

DetailFragment.kt

import androidx.navigation.fragment.findNavController

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

Rozoznanie, či ide o landscape alebo portrait režim je na základe toho, či v príslušnom layoute existuje element s daným ID.

Triedu CallsViewModel zmeníme, aby rozširovala triedu AndroidViewModel. Získame tak prístup k application context.

CallsViewModel.kt

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

Vytvoríme premennú contentResolver na prístup k dátam.

CallsViewModel.kt

private val contentResolver by lazy {
        application.contentResolver
}

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.

CallsViewModel.kt

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")
        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
}

Content providers

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()).

CallsViewModel

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

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

            override fun onChange(selfChange: Boolean) {
                postValue(loadCalls())
            }

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

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

Volanie observer.onChange(true) spôsobí, že sa pri zaregistrovaní na sledovanie zmien automaticky načítajú dáta.

V MasterFragment je potrebné aktuálne volanie adapter.submitList(model.calls) zameniť za

MasterFragment.kt

model.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

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

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

private fun init(){ 
    ...
}

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).

Pri vlastných jednoduchých aplikáciach sa vieme vyhnúť takémuto kódu tak, že povolenie môžeme udeliť priamo v nastaveniach telefónu (v sekcii aplikácie, povolenia a pod.).

MasterFragment.kt

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

MasterFragment.kt

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
            }
            else -> {
                requestPermissionLauncher.launch(android.Manifest.permission.READ_CALL_LOG)
            }
        }
    }

Pridáme potrebné importy a aplikáciu spustíme.

Gitlab kód