Úlohy pre 6. cvičenie

  • 1. Pridanie SQLite databazy
  • 2. Ziskanie polohy a vytvorenie Geofence okolo nej

Hodnotenie

Za splnenie prvej úlohy celkovo 2 body.

Za splnenie druhej úlohy celkovo 1 bod.

1. Pridanie SQLite databazy

1. Pridanie závislostí do projektu

Room je časť Android Architecture Components, ktoré poskytujú robustnú databázovú vrstvu pre vaše aplikácie. Kotlin Coroutines ponúka elegantný spôsob práce s asynchrónnymi operáciami, čo je ideálne pre databázové operácie, ktoré môžu trvať dlhšie a mali by sa vykonávať mimo hlavného vlákna.

Pridajte nasledujúce závislosti do vášho `build.gradle` (Module: app):

    val room_version = "2.6.0"
    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")
    kapt("androidx.room:room-compiler:$room_version")
    implementation("androidx.room:room-ktx:$room_version")
        
Po pridaní závislostí kliknite na 'Sync Now' v Android Studio, aby sa závislosti stiahli a integrovali do vášho projektu. Keď je táto závislosť pridaná, môžete v DAO definovať metódy, ktoré vrátia `suspend` funkcie. Použitím `suspend` funkcií s Room a coroutines môžete jednoducho a bezpečne pristupovať k databáze mimo hlavného vlákna.

2. Vytvorenie entít

Entita je základný stavebný kameň Room databázy, ktorý reprezentuje tabuľku. Každá entita sa mapuje na tabuľku v databáze.
@Entity(tableName = "users")
class UserEntity(
    @PrimaryKey val uid: String,
    val name: String,
    val updated: String,
    val lat: Double,
    val lon: Double,
    val radius: Double,
    val photo: String = ""
)
        
V tomto príklade je trieda `UserEntity` mapovaná na tabuľku s názvom "users". Atribút `@PrimaryKey` definuje, ktorý stĺpec je primárnym kľúčom v tejto tabuľke.

3. Vytvorenie DAO (Data Access Object)

DAO je rozhranie, ktoré definuje všetky potrebné metódy pre interakciu s databázou.

@Dao
interface DbDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUserItems(items: List<UserEntity>)

    @Query("select * from users where uid=:uid")
    fun getUserItem(uid: String): LiveData<UserEntity?>

    @Query("select * from users")
    fun getUsers(): LiveData<List<UserEntity?>>

    @Query("delete from users")
    suspend fun deleteUserItems()

}

Tu `@Query` definuje SQL dotaz, `@Insert` je pre vkladanie nových záznamov. Potom existuje aj `@Update` pre aktualizáciu existujúcich záznamov a `@Delete` pre vymazanie záznamov.

4. Vytvorenie databázy

Na centralizáciu databázovej logiky vytvorte abstraktnú triedu, ktorá rozširuje `RoomDatabase`.

@Database(
    entities = [
        UserEntity::class
    ],
    version = 1,
    exportSchema = false
)
abstract class AppRoomDatabase : RoomDatabase() {

    abstract fun appDao(): DbDao

    companion object {
        @Volatile
        private var INSTANCE: AppRoomDatabase? = null

        fun getInstance(context: Context): AppRoomDatabase =
            INSTANCE ?: synchronized(this) {
                INSTANCE
                    ?: buildDatabase(context).also { INSTANCE = it }
            }

        private fun buildDatabase(context: Context) =
            Room.databaseBuilder(
                context.applicationContext,
                AppRoomDatabase::class.java, "treeCamDB"
            ).fallbackToDestructiveMigration()
                .build()
    }
}

@Database je anotácia, ktorá označuje triedu ako Room databázu. Má nasledujúce atribúty:
  • entities: Zoznam všetkých entít, ktoré budú v databáze reprezentované ako tabuľky. V tomto prípade obsahuje iba UserEntity::class.
  • version: Verzia databázy. Keď urobíte zmeny v štruktúre databázy, mali by ste zvýšiť túto hodnotu.
  • exportSchema: Určuje, či má byť schéma databázy exportovaná do súboru pre kontrolu. V tomto prípade je nastavené na false.
abstract class AppRoomDatabase : RoomDatabase() - Táto trieda je abstraktná definícia databázy, ktorá rozširuje RoomDatabase.

abstract fun appDao(): DbDao - Táto metóda deklaruje, že naša databáza bude obsahovať DAO s názvom DbDao. Týmto spôsobom môžeme získať prístup k metódam DAO, keď máme inštanciu databázy.

companion object je špeciálny objekt v Kotlin, ktorý obsahuje metódy a premenné, ktoré môžu byť volané na úrovni triedy.

  • @Volatile: Označuje premennú INSTANCE tak, aby zmeny v tejto premennej boli okamžite viditeľné všetkým ostatným vláknam.
  • getInstance: Táto metóda zabezpečuje, že máme iba jednu inštanciu AppRoomDatabase v celej aplikácii (tzv. Singleton vzor). Ak INSTANCE nie je nastavená, volá sa buildDatabase a vytvorí sa nová inštancia databázy.
  • buildDatabase: Táto metóda vytvára novú inštanciu AppRoomDatabase použitím Room.databaseBuilder(). fallbackToDestructiveMigration() zabezpečuje, že ak dojde k nezlučiteľným zmenám v databázovej štruktúre medzi migráciami, stávajúca databáza sa zmaže a vytvorí sa znova namiesto toho, aby aplikácia spadla.

5. Vrstva LocalCache

V architektúre aplikácie je často užitočné mať medzivrstvu medzi DAO (Data Access Object) a Repository vrstvou. Táto vrstva sa môže volať LocalCache. Hlavným účelom tejto vrstvy je poskytnúť flexibilitu pri práci s databázou a umožniť zmeny v logike práce s databázou bez toho, aby to ovplyvnilo Repository alebo zvyšok aplikácie.
  • Oddelenie logiky: Keď chcete zmeniť spôsob, akým sa údaje ukladajú alebo získavajú z databázy, môžete to jednoducho urobiť v LocalCache vrstve bez toho, aby ste menili DAO alebo Repository.
  • Zlepšená testovateľnosť: S oddelenou vrstvou LocalCache môžete jednoducho napísať jednotkové testy pre túto vrstvu, čím zabezpečíte, že logika ukladania a načítavania údajov funguje správne.
  • Flexibilita: Ak sa rozhodnete zmeniť databázový nástroj alebo migráciu na iný druh úložiska, vrstva LocalCache vám umožní tieto zmeny uskutočniť bez veľkého vplyvu na zvyšok aplikácie.
  • Optimalizácia: Vrstva LocalCache môže tiež zahrňovať optimalizácie, ako sú dávkové operácie alebo medzipamäťovanie, aby sa zvýšila rýchlosť a výkon práce s databázou.
V závere, pridanie vrstvy LocalCache medzi DAO a Repository vám poskytne väčšiu kontrolu a flexibilitu nad tým, ako vaša aplikácia interaguje s databázou, a zároveň zachová čistotu a organizáciu vášho kódu.

class LocalCache(private val dao: DbDao) {

    suspend fun logoutUser() {
        deleteUserItems()
    }

    suspend fun insertUserItems(items: List<UserEntity>) {
        dao.insertUserItems(items)
    }

    fun getUserItem(uid: String): LiveData<UserEntity?> {
        return dao.getUserItem(uid)
    }

    fun getUsers(): LiveData<List<UserEntity?>> = dao.getUsers()

    suspend fun deleteUserItems() {
        dao.deleteUserItems()
    }

}

6. Komunikacia s databazou cez DataRepository

Trieda DataRepository predstavuje kľúčový komponent v architektúre vašej aplikácie. Slúži ako stredná vrstva medzi zdrojmi údajov (v tomto prípade API a lokálnou databázou) a zvyškom vašej aplikácie.

Hlavným účelom tejto triedy je poskytnúť ústredné miesto pre získavanie údajov, či už ide o načítavanie údajov z API alebo ich získavanie z lokálnej databázy. Týmto spôsobom, ak by ste potrebovali zmeniť logiku získavania údajov, môžete to urobiť na jednom mieste, bez toho, aby ste museli meniť mnoho častí aplikácie.

Vďaka Singleton dizajnovému vzoru máte zabezpečené, že v rámci celej aplikácie existuje iba jedna inštancia DataRepository. To zabezpečuje konzistentnosť údajov a minimalizuje riziko chýb spojených s viacnásobným prístupom k zdrojom údajov.

Metóda apiListGeofence() je zodpovedná za komunikáciu s API a zabezpečuje, že údaje sú správne načítané a následne uložené do lokálnej databázy. Na druhej strane metóda getUsers() poskytuje rýchly prístup k údajom uloženým v databáze.




class DataRepository private constructor(
    private val service: ApiService,
    private val cache: LocalCache
) {
    companion object {
        const val TAG = "DataRepository"

        @Volatile
        private var INSTANCE: DataRepository? = null
        private val lock = Any()

        fun getInstance(context: Context): DataRepository =
            INSTANCE ?: synchronized(lock) {
                INSTANCE
                    ?: DataRepository(
                        ApiService.create(context),
                        LocalCache(AppRoomDatabase.getInstance(context).appDao())
                    ).also { INSTANCE = it }
            }
    }  
    suspend fun apiListGeofence(): String {
        try {
            val response = service.listGeofence()

            if (response.isSuccessful) {
                response.body()?.let {
                    val users = it.map {
                        UserEntity(
                            it.uid, it.name, it.updated,
                            it.lat, it.lon, it.radius, it.photo
                        )
                    }
                    cache.insertUserItems(users)
                    return ""
                }
            }

            return "Failed to load users"
        } catch (ex: IOException) {
            ex.printStackTrace()
            return "Check internet connection. Failed to load users."
        } catch (ex: Exception) {
            ex.printStackTrace()
        }
        return "Fatal error. Failed to load users."
    }

    fun getUsers() = cache.getUsers()
}

Trieda DataRepository je dizajnovaná ako Singleton, čo znamená, že v rámci aplikácie bude existovať iba jedna inštancia tejto triedy. Je zodpovedná za získavanie údajov z API a ukladanie ich do lokálnej databázy pomocou LocalCache.
  • service: Inštancia API služby.
  • cache: Lokálna medzipamäť (LocalCache) pre prácu s databázou.
companion object obsahuje metódy a premenné, ktoré môžu byť volané na úrovni triedy.
  • @Volatile: Označuje premennú INSTANCE tak, aby zmeny v tejto premennej boli okamžite viditeľné všetkým ostatným vláknam.
  • getInstance: Táto metóda zabezpečuje, že máme iba jednu inštanciu DataRepository v celej aplikácii.
suspend fun apiListGeofence() funkcia je určená na získavanie údajov z API služby. Po úspešnom získaní údajov sa tieto údaje prevedú na entity a uložia sa do databázy pomocou LocalCache.
  • try-catch: Ošetruje možné chyby počas komunikácie s API alebo pri iných chybách.
  • response.isSuccessful: Kontroluje, či bola odpoveď od API úspešná.
  • cache.insertUserItems(users): Ukladá získané údaje do databázy.
getUsers() metóda vráti zoznam používateľov uložených v lokálnej databáze. Je to priamy prístup k LocalCache pre získanie uložených údajov.

7. Nacitavanie a zobrazovanie data pouzivatelovi ( z Databazy/API )

Trieda FeedViewModel predstavuje jednu z kľúčových súčastí architektúry MVVM (Model-View-ViewModel) v Android aplikáciách. Jej úloha spočíva v spracovaní a poskytovaní údajov pre užívateľské rozhranie a správe užívateľských interakcií s týmito údajmi.

Prístup k dátam prostredníctvom repozitára: Hlavnou výhodou použitia repozitára v kombinácii s ViewModelom je oddeľovanie logiky získavania dát od logiky zobrazenia. V triede FeedViewModel je repozitár využívaný ako most medzi databázou a API službou. Keď si užívateľ vyžiada aktualizáciu údajov, ViewModel prostredníctvom repozitára okamžite získava dáta z lokálnej databázy a súčasne inicializuje požiadavku na získanie aktualizovaných údajov z API.

Princíp práce s dátami: Základným princípom je poskytnúť užívateľovi okamžitý prístup k dátam z lokálnej databázy, čím sa minimalizuje čakanie. Po načítaní dát z API sú tieto údaje synchronizované a aktualizované v lokálnej databáze, čo následne ovplyvní zobrazenie v užívateľskom rozhraní. Týmto spôsobom sa dosahuje kombinácia rýchlosti (vďaka lokálnym údajom) a aktuálnosti (vďaka online údajom z API).

Indikácia načítavania a správa o chybách: Aby užívateľ bol v obraze o tom, čo sa deje v aplikácii, ViewModel obsahuje logiku na zobrazovanie stavu načítavania a prípadných chybových hlásení. Premenná loading sleduje, či práve prebieha načítavanie údajov. Funkcia updateItems() sa postará o celý proces aktualizácie údajov – od zahájenia načítavania, cez spracovanie chýb až po ukončenie načítavania.

Výhody lokálnej databázy: Lokálna databáza je kľúčovým prvkom pre zabezpečenie plynulosti aplikácie v rôznych sieťových podmienkach. Keď je internetové pripojenie slabé alebo dokonca nedostupné, užívateľ stále môže pracovať s údajmi, ktoré boli naposledy synchronizované. Toto riešenie zabezpečuje, že aplikácia je oveľa viac odolná voči nestabilitám siete a poskytuje užívateľovi konzistentné skúsenosti nezávisle od kvality internetového pripojenia.

Na záver, trieda FeedViewModel demonštruje silnú kombináciu MVVM architektúry, repozitára a lokálnej databázy, vďaka čomu je možné efektívne a efektívne pracovať s údajmi v modernej Android aplikácii.


class FeedViewModel(private val repository: DataRepository) : ViewModel() {

    val feed_items: LiveData<List<UserEntity?>> =
        liveData {
            loading.postValue(true)
            repository.apiListGeofence()
            loading.postValue(false)
            emitSource(repository.getUsers())
        }

    val loading = MutableLiveData(false)

    private val _message = MutableLiveData<Evento<String>>()
    val message: LiveData<Evento<String>>
        get() = _message

    fun updateItems() {
        viewModelScope.launch {
            loading.postValue(true)
            _message.postValue(Evento(repository.apiListGeofence()))
            loading.postValue(false)
        }
    }
}

2. Ziskanie polohy a vytvorenie Geofence okolo nej

Povolenie prístupu k lokalizácii na pozadí

Povolenie <uses-permission-sdk-23 android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> v súbore AndroidManifest.xml je nevyhnutné pre aplikácie, ktoré potrebujú získavať prístup k lokalizácii zariadenia, keď aplikácia beží na pozadí alebo nie je aktívna na obrazovke. Toto povolenie je špecifikované pre aplikácie cieľujúce na Android SDK verziu 23 a vyššiu. Od tejto verzie Androidu sú pravidlá pre prístup k lokalizácii na pozadí prísnejšie kvôli ochrane súkromia užívateľa. Aplikácie, ktoré chcú využívať túto schopnosť, by mali byť transparentné voči užívateľovi a jasne komunikovať, prečo potrebujú takýto prístup, aby mohli získať potrebné povolenie od užívateľa.

Nižšie uvádzam ukážkový kód ako je možné overiť či použivateľ udelil dostatočné povolenia, a tiež ako ich vyžiadať ak ich ešte nepovolil.


private val PERMISSIONS_REQUIRED = when {
    Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
        arrayOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.ACCESS_COARSE_LOCATION,
            Manifest.permission.ACCESS_BACKGROUND_LOCATION
        )
    }

    else -> {
        arrayOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.ACCESS_COARSE_LOCATION
        )
    }
}

val requestPermissionLauncher =
    registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) {

    }

fun hasPermissions(context: Context) = PERMISSIONS_REQUIRED.all {
    ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}

Krok 1: Získanie Aktuálnej Polohy

Pred vytvorením geofence potrebujete získať aktuálnu polohu zariadenia. To môžete urobiť použitím FusedLocationProviderClient. Nezabudnite skontrolovať, či máte povolenie na prístup k polohe pred tým, ako získate polohu.


val fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireActivity())
fusedLocationClient.lastLocation.addOnSuccessListener(requireActivity()) {
    // Logika pre prácu s poslednou polohou
    Log.d("ProfileFragment", "poloha posledna $it")
    setupGeofence(it)
}

Krok 2: Vytvorenie Geofence

Po získaní aktuálnej polohy môžete nastaviť geofence. Tu je príklad, ako vytvoriť geofence s určitým polomerom okolo aktuálnej polohy:


val geofencingClient = LocationServices.getGeofencingClient(requireActivity())

val geofence = Geofence.Builder()
    .setRequestId("my-geofence")
    .setCircularRegion(location.latitude, location.longitude, 100f) // 100m polomer
    .setExpirationDuration(Geofence.NEVER_EXPIRE)
    .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_EXIT)
    .build()

Krok 3: Požiadavka na Geofencing

Ďalej potrebujete vytvoriť požiadavku na geofencing a pridať geofence. Geofencing je technológia, ktorá umožňuje aplikácii reagovať na vstup alebo výstup z definovanej geografickej oblasti, čo sa často nazýva "virtuálny plot".

Vytvorenie objektu GeofencingRequest: Prostredníctvom konštruktora Builder() sa vytvára nový objekt GeofencingRequest. Táto konštrukcia umožňuje nastaviť rôzne parametre a vlastnosti pre požiadavku na geofencing.

Nastavenie počiatočneho stavu: Metóda setInitialTrigger() definuje, aká akcia spustí upozornenie geofencingu. V tomto prípade je nastavená na INITIAL_TRIGGER_ENTER, čo znamená, že upozornenie bude spustené hneď ako užívateľ vstúpi do definovanej oblasti.

Pridanie geofence: Metóda addGeofence() pridáva konkrétny geofence (virtuálny plot) do požiadavky. V tomto prípade je geofence predom definovaný objekt, ktorý určuje geografické hranice a pravidlá pre geofencing.

Nakoniec metóda build() vytvára a vráti objekt GeofencingRequest s nastavenými vlastnosťami.

Výsledná požiadavka na geofencing môže byť následne použitá s rôznymi službami, ktoré monitorujú polohu zariadenia a vyvolávajú upozornenia alebo akcie, keď sú splnené podmienky geofencingu.


val geofencingRequest = GeofencingRequest.Builder()
    .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
    .addGeofence(geofence)
    .build()

Krok 4: Geofence PendingIntent

Na zachytenie udalostí geofence potrebujete PendingIntent, ktorý spustí službu alebo vysielanie, keď sa vykoná udalosť geofence:


val intent = Intent(requireActivity(), GeofenceBroadcastReceiver::class.java)
val geofencePendingIntent =
    PendingIntent.getBroadcast(
        requireActivity(),
        0,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT
    )

V danom kóde sa vytvára Intent, ktorý definuje, že chceme spustiť službu GeofenceBroadcastReceiver. Tento Intent je potom zabalený do PendingIntent, čo umožňuje externým aplikáciám alebo službám spustiť tento Intent v našom mene. Použitím PendingIntent.FLAG_UPDATE_CURRENT zabezpečujeme, že ak už existuje takýto PendingIntent, bude aktualizovaný novými dátami z aktuálneho Intentu.

Upozornenie: GeofenceBroadcastReceiver nebude Android Studio poznať, keďže ste ho ešte neimplementovali, jeho implementácia je nižšie.

Krok 5: Pridanie Geofences k LocationServices.getGeofencingClient

Nakoniec, pridajte váš geofence pomocou LocationServices.getGeofencingClient:


geofencingClient.addGeofences(geofencingRequest, geofencePendingIntent).run {
    addOnSuccessListener {
        // Geofences boli úspešne pridané
        Log.d("ProfileFragment", "geofence vytvoreny")
    }
    addOnFailureListener {
        // Chyba pri pridaní geofences
        it.printStackTrace()
        binding.locationSwitch.isChecked = false
        PreferenceData.getInstance().putSharing(requireContext(), false)
    }
}

Spracovanie Geofence na pozadi

Ak chcete reagovať na prechody geofence, vytvorte BroadcastReceiver, ktorý zachytí udalosti geofence. Počas udalosti EXIT môžete získať novú polohu na pozadí:

class GeofenceBroadcastReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context?, intent: Intent?) {
        if (intent == null) {
            // no geofence exit
            Log.e("GeofenceBroadcastReceiver", "error 1")
            return
        }

        val geofencingEvent = GeofencingEvent.fromIntent(intent)

        if (geofencingEvent == null) {
            // error
            Log.e("GeofenceBroadcastReceiver", "error 2")
            return
        }

        if (geofencingEvent.hasError()) {
            val errorMessage = GeofenceStatusCodes
                .getStatusCodeString(geofencingEvent.errorCode)
            //send error message to user using notification
            Log.e("GeofenceBroadcastReceiver", "error 3")
            return
        }

        // Get the transition type.
        val geofenceTransition = geofencingEvent.geofenceTransition

        if (geofenceTransition == Geofence.GEOFENCE_TRANSITION_EXIT) {
            val triggeringLocation = geofencingEvent.triggeringLocation
            if (context == null || triggeringLocation == null) {
                // error
                Log.e("GeofenceBroadcastReceiver", "error 4")
                return
            }
            setupGeofence(triggeringLocation, context)
        }
    }

    private fun setupGeofence(location: Location, context: Context) {

        val geofencingClient = LocationServices.getGeofencingClient(context.applicationContext)

        val geofence = Geofence.Builder()
            .setRequestId("my-geofence")
            .setCircularRegion(location.latitude, location.longitude, 100f) // 100m polomer
            .setExpirationDuration(Geofence.NEVER_EXPIRE)
            .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_EXIT)
            .build()

        val geofencingRequest = GeofencingRequest.Builder()
            .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
            .addGeofence(geofence)
            .build()

        val intent = Intent(context, GeofenceBroadcastReceiver::class.java)
        val geofencePendingIntent =
            PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)

        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.ACCESS_FINE_LOCATION
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            // permission issue, geofence not created
            Log.e("GeofenceBroadcastReceiver", "error 5")
            return
        }
        geofencingClient.addGeofences(geofencingRequest, geofencePendingIntent).run {
            addOnSuccessListener {
                // Geofences boli úspešne pridané
                Log.d("GeofenceBroadcastReceiver", "novy geofence vytvoreny")
            }
            addOnFailureListener {
                // Chyba pri pridaní geofences
                Log.e("GeofenceBroadcastReceiver", "error 6")
            }
        }

    }
}

Preštudujte a odskúšajte si kódy hlavne z týchto stránok: