aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/foundation/e/advancedprivacy/data
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/foundation/e/advancedprivacy/data')
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt281
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/data/repositories/CityDataSource.kt46
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/data/repositories/LocalStateRepository.kt116
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/data/repositories/TrackersRepository.kt129
4 files changed, 572 insertions, 0 deletions
diff --git a/app/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt
new file mode 100644
index 0000000..0b951a8
--- /dev/null
+++ b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt
@@ -0,0 +1,281 @@
+/*
+ * Copyright (C) 2022 E FOUNDATION, 2022 - 2023 MURENA SAS
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package foundation.e.advancedprivacy.data.repositories
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
+import foundation.e.advancedprivacy.R
+import foundation.e.privacymodules.permissions.PermissionsPrivacyModule
+import foundation.e.privacymodules.permissions.data.ApplicationDescription
+import foundation.e.privacymodules.permissions.data.ProfileType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+class AppListsRepository(
+ private val permissionsModule: PermissionsPrivacyModule,
+ private val context: Context,
+ private val coroutineScope: CoroutineScope
+) {
+ companion object {
+ private const val PNAME_SETTINGS = "com.android.settings"
+ private const val PNAME_PWAPLAYER = "foundation.e.pwaplayer"
+ private const val PNAME_INTENT_VERIFICATION = "com.android.statementservice"
+ private const val PNAME_MICROG_SERVICES_CORE = "com.google.android.gms"
+
+ val compatibiltyPNames = setOf(
+ PNAME_PWAPLAYER, PNAME_INTENT_VERIFICATION, PNAME_MICROG_SERVICES_CORE
+ )
+ }
+
+ val dummySystemApp = ApplicationDescription(
+ packageName = "foundation.e.dummysystemapp",
+ uid = -1,
+ label = context.getString(R.string.dummy_system_app_label),
+ icon = context.getDrawable(R.drawable.ic_e_app_logo),
+ profileId = -1,
+ profileType = ProfileType.MAIN
+ )
+
+ val dummyCompatibilityApp = ApplicationDescription(
+ packageName = "foundation.e.dummyappscompatibilityapp",
+ uid = -2,
+ label = context.getString(R.string.dummy_apps_compatibility_app_label),
+ icon = context.getDrawable(R.drawable.ic_apps_compatibility_components),
+ profileId = -1,
+ profileType = ProfileType.MAIN
+ )
+
+ private suspend fun fetchAppDescriptions(fetchMissingIcons: Boolean = false) {
+ val launcherPackageNames = context.packageManager.queryIntentActivities(
+ Intent(Intent.ACTION_MAIN, null).apply { addCategory(Intent.CATEGORY_LAUNCHER) },
+ 0
+ ).mapNotNull { it.activityInfo?.packageName }
+
+ val visibleAppsFilter = { packageInfo: PackageInfo ->
+ hasInternetPermission(packageInfo) &&
+ isStandardApp(packageInfo.applicationInfo, launcherPackageNames)
+ }
+
+ val hiddenAppsFilter = { packageInfo: PackageInfo ->
+ hasInternetPermission(packageInfo) &&
+ isHiddenSystemApp(packageInfo.applicationInfo, launcherPackageNames)
+ }
+
+ val compatibilityAppsFilter = { packageInfo: PackageInfo ->
+ packageInfo.packageName in compatibiltyPNames
+ }
+
+ val visibleApps = recycleIcons(
+ newApps = permissionsModule.getApplications(visibleAppsFilter),
+ fetchMissingIcons = fetchMissingIcons
+ )
+ val hiddenApps = permissionsModule.getApplications(hiddenAppsFilter)
+ val compatibilityApps = permissionsModule.getApplications(compatibilityAppsFilter)
+
+ updateMaps(visibleApps + hiddenApps + compatibilityApps)
+
+ allProfilesAppDescriptions.emit(
+ Triple(
+ visibleApps + dummySystemApp + dummyCompatibilityApp,
+ hiddenApps,
+ compatibilityApps
+ )
+ )
+ }
+
+ private fun recycleIcons(
+ newApps: List<ApplicationDescription>,
+ fetchMissingIcons: Boolean
+ ): List<ApplicationDescription> {
+ val oldVisibleApps = allProfilesAppDescriptions.value.first
+ return newApps.map { app ->
+ app.copy(
+ icon = oldVisibleApps.find { app.apId == it.apId }?.icon
+ ?: if (fetchMissingIcons) permissionsModule.getApplicationIcon(app) else null
+ )
+ }
+ }
+
+ private fun updateMaps(apps: List<ApplicationDescription>) {
+ val byUid = mutableMapOf<Int, ApplicationDescription>()
+ val byApId = mutableMapOf<String, ApplicationDescription>()
+ apps.forEach { app ->
+ byUid[app.uid]?.run { packageName > app.packageName } == true
+ if (byUid[app.uid].let { it == null || it.packageName > app.packageName }) {
+ byUid[app.uid] = app
+ }
+
+ byApId[app.apId] = app
+ }
+ appsByUid = byUid
+ appsByAPId = byApId
+ }
+
+ private var lastFetchApps = 0
+ private var refreshAppJob: Job? = null
+ private fun refreshAppDescriptions(fetchMissingIcons: Boolean = true, force: Boolean = false): Job? {
+ if (refreshAppJob == null) {
+ refreshAppJob = coroutineScope.launch(Dispatchers.IO) {
+ if (force || context.packageManager.getChangedPackages(lastFetchApps) != null) {
+ fetchAppDescriptions(fetchMissingIcons = fetchMissingIcons)
+ if (fetchMissingIcons) {
+ lastFetchApps = context.packageManager.getChangedPackages(lastFetchApps)
+ ?.sequenceNumber ?: lastFetchApps
+ }
+
+ refreshAppJob = null
+ }
+ }
+ }
+
+ return refreshAppJob
+ }
+
+ fun mainProfileApps(): Flow<List<ApplicationDescription>> {
+ refreshAppDescriptions()
+ return allProfilesAppDescriptions.map {
+ it.first.filter { app -> app.profileType == ProfileType.MAIN }
+ .sortedBy { app -> app.label.toString().lowercase() }
+ }
+ }
+
+ fun getMainProfileHiddenSystemApps(): List<ApplicationDescription> {
+ return allProfilesAppDescriptions.value.second.filter { it.profileType == ProfileType.MAIN }
+ }
+
+ fun apps(): Flow<List<ApplicationDescription>> {
+ refreshAppDescriptions()
+ return allProfilesAppDescriptions.map {
+ it.first.sortedBy { app -> app.label.toString().lowercase() }
+ }
+ }
+
+ fun allApps(): Flow<List<ApplicationDescription>> {
+ return allProfilesAppDescriptions.map {
+ it.first + it.second + it.third
+ }
+ }
+
+ private fun getHiddenSystemApps(): List<ApplicationDescription> {
+ return allProfilesAppDescriptions.value.second
+ }
+
+ private fun getCompatibilityApps(): List<ApplicationDescription> {
+ return allProfilesAppDescriptions.value.third
+ }
+
+ fun anyForHiddenApps(app: ApplicationDescription, test: (ApplicationDescription) -> Boolean): Boolean {
+ return if (app == dummySystemApp) {
+ getHiddenSystemApps().any { test(it) }
+ } else if (app == dummyCompatibilityApp) {
+ getCompatibilityApps().any { test(it) }
+ } else test(app)
+ }
+
+ fun applyForHiddenApps(app: ApplicationDescription, action: (ApplicationDescription) -> Unit) {
+ mapReduceForHiddenApps(app = app, map = action, reduce = {})
+ }
+
+ fun <T, R> mapReduceForHiddenApps(
+ app: ApplicationDescription,
+ map: (ApplicationDescription) -> T,
+ reduce: (List<T>) -> R
+ ): R {
+ return if (app == dummySystemApp) {
+ reduce(getHiddenSystemApps().map(map))
+ } else if (app == dummyCompatibilityApp) {
+ reduce(getCompatibilityApps().map(map))
+ } else reduce(listOf(map(app)))
+ }
+
+ private var appsByUid = mapOf<Int, ApplicationDescription>()
+ private var appsByAPId = mapOf<String, ApplicationDescription>()
+
+ fun getApp(appUid: Int): ApplicationDescription? {
+ return appsByUid[appUid] ?: run {
+ runBlocking { refreshAppDescriptions(fetchMissingIcons = false, force = true)?.join() }
+ appsByUid[appUid]
+ }
+ }
+
+ fun getApp(apId: String): ApplicationDescription? {
+ if (apId.isBlank()) return null
+
+ return appsByAPId[apId] ?: run {
+ runBlocking { refreshAppDescriptions(fetchMissingIcons = false, force = true)?.join() }
+ appsByAPId[apId]
+ }
+ }
+
+ private val allProfilesAppDescriptions = MutableStateFlow(
+ Triple(
+ emptyList<ApplicationDescription>(),
+ emptyList<ApplicationDescription>(),
+ emptyList<ApplicationDescription>()
+ )
+ )
+
+ private fun hasInternetPermission(packageInfo: PackageInfo): Boolean {
+ return packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
+ }
+
+ @Suppress("ReturnCount")
+ private fun isNotHiddenSystemApp(app: ApplicationInfo, launcherApps: List<String>): Boolean {
+ if (app.packageName == PNAME_SETTINGS) {
+ return false
+ } else if (app.packageName == PNAME_PWAPLAYER) {
+ return true
+ } else if (app.hasFlag(ApplicationInfo.FLAG_UPDATED_SYSTEM_APP)) {
+ return true
+ } else if (!app.hasFlag(ApplicationInfo.FLAG_SYSTEM)) {
+ return true
+ } else if (launcherApps.contains(app.packageName)) {
+ return true
+ }
+ return false
+ }
+
+ private fun isStandardApp(app: ApplicationInfo, launcherApps: List<String>): Boolean {
+ return when {
+ app.packageName == PNAME_SETTINGS -> false
+ app.packageName in compatibiltyPNames -> false
+ app.hasFlag(ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) -> true
+ !app.hasFlag(ApplicationInfo.FLAG_SYSTEM) -> true
+ launcherApps.contains(app.packageName) -> true
+ else -> false
+ }
+ }
+
+ private fun isHiddenSystemApp(app: ApplicationInfo, launcherApps: List<String>): Boolean {
+ return when {
+ app.packageName in compatibiltyPNames -> false
+ else -> !isNotHiddenSystemApp(app, launcherApps)
+ }
+ }
+
+ private fun ApplicationInfo.hasFlag(flag: Int) = (flags and flag) == 1
+}
diff --git a/app/src/main/java/foundation/e/advancedprivacy/data/repositories/CityDataSource.kt b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/CityDataSource.kt
new file mode 100644
index 0000000..06fb9ac
--- /dev/null
+++ b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/CityDataSource.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2021 E FOUNDATION
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package foundation.e.advancedprivacy.dummy
+
+object CityDataSource {
+ private val BARCELONA = Pair(41.3851f, 2.1734f)
+ private val BUDAPEST = Pair(47.4979f, 19.0402f)
+ private val ABU_DHABI = Pair(24.4539f, 54.3773f)
+ private val HYDERABAD = Pair(17.3850f, 78.4867f)
+ private val QUEZON_CITY = Pair(14.6760f, 121.0437f)
+ private val PARIS = Pair(48.8566f, 2.3522f)
+ private val LONDON = Pair(51.5074f, 0.1278f)
+ private val SHANGHAI = Pair(31.2304f, 121.4737f)
+ private val MADRID = Pair(40.4168f, -3.7038f)
+ private val LAHORE = Pair(31.5204f, 74.3587f)
+ private val CHICAGO = Pair(41.8781f, -87.6298f)
+
+ val citiesLocationsList = listOf(
+ BARCELONA,
+ BUDAPEST,
+ ABU_DHABI,
+ HYDERABAD,
+ QUEZON_CITY,
+ PARIS,
+ LONDON,
+ SHANGHAI,
+ MADRID,
+ LAHORE,
+ CHICAGO
+ )
+}
diff --git a/app/src/main/java/foundation/e/advancedprivacy/data/repositories/LocalStateRepository.kt b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/LocalStateRepository.kt
new file mode 100644
index 0000000..3f73c78
--- /dev/null
+++ b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/LocalStateRepository.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2021 E FOUNDATION
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package foundation.e.advancedprivacy.data.repositories
+
+import android.content.Context
+import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode
+import foundation.e.advancedprivacy.domain.entities.LocationMode
+import foundation.e.privacymodules.permissions.data.ApplicationDescription
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+class LocalStateRepository(context: Context) {
+ companion object {
+ private const val SHARED_PREFS_FILE = "localState"
+ private const val KEY_BLOCK_TRACKERS = "blockTrackers"
+ private const val KEY_IP_SCRAMBLING = "ipScrambling"
+ private const val KEY_FAKE_LOCATION = "fakeLocation"
+ private const val KEY_FAKE_LATITUDE = "fakeLatitude"
+ private const val KEY_FAKE_LONGITUDE = "fakeLongitude"
+ private const val KEY_FIRST_BOOT = "firstBoot"
+ private const val KEY_HIDE_WARNING_TRACKERS = "hide_warning_trackers"
+ private const val KEY_HIDE_WARNING_LOCATION = "hide_warning_location"
+ private const val KEY_HIDE_WARNING_IPSCRAMBLING = "hide_warning_ipscrambling"
+ }
+
+ private val sharedPref = context.getSharedPreferences(SHARED_PREFS_FILE, Context.MODE_PRIVATE)
+
+ private val _blockTrackers = MutableStateFlow(sharedPref.getBoolean(KEY_BLOCK_TRACKERS, true))
+ val blockTrackers = _blockTrackers.asStateFlow()
+
+ fun setBlockTrackers(enabled: Boolean) {
+ set(KEY_BLOCK_TRACKERS, enabled)
+ _blockTrackers.update { enabled }
+ }
+
+ val areAllTrackersBlocked: MutableStateFlow<Boolean> = MutableStateFlow(false)
+
+ private val _fakeLocationEnabled = MutableStateFlow(sharedPref.getBoolean(KEY_FAKE_LOCATION, false))
+
+ val fakeLocationEnabled = _fakeLocationEnabled.asStateFlow()
+
+ fun setFakeLocationEnabled(enabled: Boolean) {
+ set(KEY_FAKE_LOCATION, enabled)
+ _fakeLocationEnabled.update { enabled }
+ }
+
+ var fakeLocation: Pair<Float, Float>
+ get() = Pair(
+ // Initial default value is Quezon City
+ sharedPref.getFloat(KEY_FAKE_LATITUDE, 14.6760f),
+ sharedPref.getFloat(KEY_FAKE_LONGITUDE, 121.0437f)
+ )
+
+ set(value) {
+ sharedPref.edit()
+ .putFloat(KEY_FAKE_LATITUDE, value.first)
+ .putFloat(KEY_FAKE_LONGITUDE, value.second)
+ .apply()
+ }
+
+ val locationMode: MutableStateFlow<LocationMode> = MutableStateFlow(LocationMode.REAL_LOCATION)
+
+ private val _ipScramblingSetting = MutableStateFlow(sharedPref.getBoolean(KEY_IP_SCRAMBLING, false))
+ val ipScramblingSetting = _ipScramblingSetting.asStateFlow()
+
+ fun setIpScramblingSetting(enabled: Boolean) {
+ set(KEY_IP_SCRAMBLING, enabled)
+ _ipScramblingSetting.update { enabled }
+ }
+
+ val internetPrivacyMode: MutableStateFlow<InternetPrivacyMode> = MutableStateFlow(InternetPrivacyMode.REAL_IP)
+
+ private val _otherVpnRunning = MutableSharedFlow<ApplicationDescription>()
+ suspend fun emitOtherVpnRunning(appDesc: ApplicationDescription) {
+ _otherVpnRunning.emit(appDesc)
+ }
+ val otherVpnRunning: SharedFlow<ApplicationDescription> = _otherVpnRunning
+
+ var firstBoot: Boolean
+ get() = sharedPref.getBoolean(KEY_FIRST_BOOT, true)
+ set(value) = set(KEY_FIRST_BOOT, value)
+
+ var hideWarningTrackers: Boolean
+ get() = sharedPref.getBoolean(KEY_HIDE_WARNING_TRACKERS, false)
+ set(value) = set(KEY_HIDE_WARNING_TRACKERS, value)
+
+ var hideWarningLocation: Boolean
+ get() = sharedPref.getBoolean(KEY_HIDE_WARNING_LOCATION, false)
+ set(value) = set(KEY_HIDE_WARNING_LOCATION, value)
+
+ var hideWarningIpScrambling: Boolean
+ get() = sharedPref.getBoolean(KEY_HIDE_WARNING_IPSCRAMBLING, false)
+ set(value) = set(KEY_HIDE_WARNING_IPSCRAMBLING, value)
+
+ private fun set(key: String, value: Boolean) {
+ sharedPref.edit().putBoolean(key, value).apply()
+ }
+}
diff --git a/app/src/main/java/foundation/e/advancedprivacy/data/repositories/TrackersRepository.kt b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/TrackersRepository.kt
new file mode 100644
index 0000000..82915df
--- /dev/null
+++ b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/TrackersRepository.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2022 E FOUNDATION
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package foundation.e.advancedprivacy.data.repositories
+
+import android.content.Context
+import android.util.Log
+import com.google.gson.Gson
+import foundation.e.privacymodules.trackers.api.Tracker
+import retrofit2.Retrofit
+import retrofit2.converter.scalars.ScalarsConverterFactory
+import retrofit2.http.GET
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileWriter
+import java.io.IOException
+import java.io.InputStreamReader
+import java.io.PrintWriter
+
+class TrackersRepository(private val context: Context) {
+
+ private val eTrackerFileName = "e_trackers.json"
+ private val eTrackerFile = File(context.filesDir.absolutePath, eTrackerFileName)
+
+ var trackers: List<Tracker> = emptyList()
+ private set
+
+ init {
+ initTrackersFile()
+ }
+
+ suspend fun update() {
+ val api = ETrackersApi.build()
+ saveData(eTrackerFile, api.trackers())
+ initTrackersFile()
+ }
+
+ private fun initTrackersFile() {
+ try {
+ var inputStream = context.assets.open(eTrackerFileName)
+ if (eTrackerFile.exists()) {
+ inputStream = FileInputStream(eTrackerFile)
+ }
+ val reader = InputStreamReader(inputStream, "UTF-8")
+ val trackerResponse =
+ Gson().fromJson(reader, ETrackersApi.ETrackersResponse::class.java)
+
+ trackers = mapper(trackerResponse)
+
+ reader.close()
+ inputStream.close()
+ } catch (e: Exception) {
+ Log.e("TrackersRepository", "While parsing trackers in assets", e)
+ }
+ }
+
+ private fun mapper(response: ETrackersApi.ETrackersResponse): List<Tracker> {
+ return response.trackers.mapNotNull {
+ try {
+ it.toTracker()
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+
+ private fun ETrackersApi.ETrackersResponse.ETracker.toTracker(): Tracker {
+ return Tracker(
+ id = id!!,
+ hostnames = hostnames!!.toSet(),
+ label = name!!,
+ exodusId = exodusId
+ )
+ }
+
+ private fun saveData(file: File, data: String): Boolean {
+ try {
+ val fos = FileWriter(file, false)
+ val ps = PrintWriter(fos)
+ ps.apply {
+ print(data)
+ flush()
+ close()
+ }
+ return true
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ return false
+ }
+}
+
+interface ETrackersApi {
+ companion object {
+ fun build(): ETrackersApi {
+ val retrofit = Retrofit.Builder()
+ .baseUrl("https://gitlab.e.foundation/e/os/tracker-list/-/raw/main/")
+ .addConverterFactory(ScalarsConverterFactory.create())
+ .build()
+ return retrofit.create(ETrackersApi::class.java)
+ }
+ }
+
+ @GET("list/e_trackers.json")
+ suspend fun trackers(): String
+
+ data class ETrackersResponse(val trackers: List<ETracker>) {
+ data class ETracker(
+ val id: String?,
+ val hostnames: List<String>?,
+ val name: String?,
+ val exodusId: String?
+ )
+ }
+}