Commit 08d240a6 authored by Daniel Wolf's avatar Daniel Wolf
Browse files

Merge branch 'master' of https://git.frostnerd.com/PublicAndroidApps/smokescreen into translations

parents bc62cfc4 5a001f40
Pipeline #5076 passed with stages
in 1 minute and 5 seconds
......@@ -107,13 +107,15 @@ dependencies {
implementation 'com.frostnerd.utilskt:preferences:1.5.14' // https://git.frostnerd.com/AndroidUtils/preferenceskt (Accessible after logging in [free of charge])
implementation 'com.frostnerd.utilskt:navigationdraweractivity:1.3.19' // https://git.frostnerd.com/AndroidUtils/navigationdraweractivity (Accessible after logging in [free of charge])
implementation 'com.frostnerd.utilskt:encrypteddnstunnelproxy:1.5.135' // https://git.frostnerd.com/AndroidUtils/encrypteddnstunnelproxy
implementation 'com.frostnerd.utilskt:encrypteddnstunnelproxy:1.5.136' // https://git.frostnerd.com/AndroidUtils/encrypteddnstunnelproxy
implementation 'com.frostnerd.utilskt:general:1.0.16' // https://git.frostnerd.com/AndroidUtils/generalkt (Accessible after logging in [free of charge])
implementation 'com.frostnerd.utilskt:adapters:1.1.1' // https://git.frostnerd.com/AndroidUtils/Adapters (Accessible after logging in [free of charge])
implementation 'androidx.work:work-runtime:2.2.0-rc01'
implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
implementation "androidx.preference:preference:1.1.0-rc01"
implementation "com.google.android.material:material:1.1.0-alpha08"
implementation "com.google.android.material:material:1.1.0-alpha09"
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0-alpha01'
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
......
......@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "daac9efbbf9d89b9aa10f7ad54ac736a",
"identityHash": "5d29ef2a948e6e550e83fcef5932253d",
"entities": [
{
"tableName": "CachedResponse",
......@@ -194,7 +194,7 @@
},
{
"tableName": "HostSource",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `ruleCount` INTEGER, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `whitelistSource` INTEGER NOT NULL)",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `ruleCount` INTEGER, `checksum` TEXT, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `whitelistSource` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
......@@ -214,6 +214,12 @@
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "checksum",
"columnName": "checksum",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
......@@ -246,7 +252,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'daac9efbbf9d89b9aa10f7ad54ac736a')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5d29ef2a948e6e550e83fcef5932253d')"
]
}
}
\ No newline at end of file
......@@ -93,6 +93,7 @@ val MIGRATION_8_9 = migration(8, 9) {
val MIGRATION_9_10 = migration(9, 10) {
Logger.logIfOpen("DB_MIGRATION", "Migrating from 9 to 10")
it.execSQL("ALTER TABLE `DnsRule` ADD COLUMN `isWildcard` INTEGER NOT NULL DEFAULT 0")
it.execSQL("ALTER TABLE `HostSource` ADD COLUMN `checksum` TEXT DEFAULT NULL")
Logger.logIfOpen("DB_MIGRATION", "Migration from 9 to 10 completed")
}
......
......@@ -45,9 +45,12 @@ interface DnsRuleDao {
@Query("DELETE FROM DnsRule WHERE importedFrom IS NULL")
fun deleteAllUserRules()
@Query("UPDATE DnsRule SET stagingType=1 WHERE importedFrom IS NOT NULL")
@Query("UPDATE DnsRule SET stagingType=1 WHERE importedFrom IS NOT NULL AND stagingType=0")
fun markNonUserRulesForDeletion()
@Query("UPDATE DnsRule SET stagingType=0 WHERE importedFrom=:hostSourceId AND stagingType=1")
fun unstageRulesOfSource(hostSourceId:Long)
@Query("DELETE FROM DnsRule WHERE stagingType=1")
fun deleteMarkedRules()
......
......@@ -47,4 +47,7 @@ interface HostSourceDao {
@Query("SELECT COUNT(*) FROM HostSource WHERE enabled > 0")
fun getEnabledCount(): Long
@Query("UPDATE HostSource SET checksum=NULL WHERE checksum IS NOT NULL AND enabled<1")
fun removeChecksumForDisabled()
}
\ No newline at end of file
......@@ -35,4 +35,7 @@ data class HostSource(
var enabled: Boolean = true
var ruleCount:Int? = null
// Content of the ETag header for supported rule sources
// Null if file-based or unknown (e.g. not requested yet)
var checksum:String? = null
}
\ No newline at end of file
package com.frostnerd.smokescreen.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.View
import android.widget.ArrayAdapter
import androidx.annotation.Keep
import androidx.appcompat.app.AlertDialog
import com.frostnerd.smokescreen.R
import com.frostnerd.smokescreen.getPreferences
import kotlinx.android.synthetic.main.dialog_host_source_refresh.view.*
/*
* Copyright (C) 2019 Daniel Wolf (Ch4t4r)
*
* 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 <http://www.gnu.org/licenses/>.
*
* You can contact the developer at daniel.wolf@frostnerd.com.
*/
class HostSourceRefreshDialog(context:Context,
runRefresh:() -> Unit,
refreshConfigChanged:() -> Unit):AlertDialog(context, context.getPreferences().theme.dialogStyle) {
init {
setTitle(R.string.dialog_hostsourcerefresh_title)
val view = layoutInflater.inflate(R.layout.dialog_host_source_refresh, null, false)
setView(view)
view.refreshNow.setOnClickListener {
runRefresh()
}
val changeAutomaticRefreshStatus:(Boolean) -> Unit = { isChecked ->
view.refreshWifiOnly.isEnabled = isChecked
view.timeAmountTil.isEnabled = isChecked
view.timeUnit.isEnabled = isChecked
view.refreshTimeWrap.visibility = if(isChecked) View.VISIBLE else View.INVISIBLE
}
view.automaticRefresh.setOnCheckedChangeListener { _, isChecked ->
changeAutomaticRefreshStatus(isChecked)
}
changeAutomaticRefreshStatus(view.automaticRefresh.isChecked)
val adapter: ArrayAdapter<CharSequence> = ArrayAdapter.createFromResource(
context,
R.array.dialog_hostsourcerefresh_timeunits,
R.layout.item_tasker_action_spinner_item
)
adapter.setDropDownViewResource(R.layout.item_tasker_action_spinner_dropdown_item)
view.timeUnit.adapter = adapter
view.automaticRefresh.isChecked = context.getPreferences().automaticHostRefresh
view.refreshWifiOnly.isChecked = context.getPreferences().automaticHostRefreshWifiOnly
view.timeAmount.setText(context.getPreferences().automaticHostRefreshTimeAmount.toString())
view.timeUnit.setSelection(context.getPreferences().automaticHostRefreshTimeUnit.ordinal)
setButton(DialogInterface.BUTTON_NEUTRAL, context.getString(android.R.string.cancel)) { dialog, _ ->
dialog.dismiss()
}
setButton(DialogInterface.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { dialog, _ ->
dialog.dismiss()
context.getPreferences().automaticHostRefresh = view.automaticRefresh.isChecked
context.getPreferences().automaticHostRefreshWifiOnly = view.refreshWifiOnly.isChecked
context.getPreferences().automaticHostRefreshTimeAmount = view.timeAmount.text.toString().toIntOrNull() ?: 1
context.getPreferences().automaticHostRefreshTimeUnit = TimeUnit.values().find { it.ordinal == view.timeUnit.selectedItemPosition }!!
refreshConfigChanged()
}
}
@Keep
enum class TimeUnit {
MINUTES, HOURS, DAYS, WEEKS
}
}
\ No newline at end of file
......@@ -12,6 +12,10 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.frostnerd.cacheadapter.ListDataSource
import com.frostnerd.cacheadapter.ModelAdapterBuilder
import com.frostnerd.general.service.isServiceRunning
......@@ -23,12 +27,15 @@ import com.frostnerd.smokescreen.database.entities.HostSource
import com.frostnerd.smokescreen.database.getDatabase
import com.frostnerd.smokescreen.dialog.DnsRuleDialog
import com.frostnerd.smokescreen.dialog.ExportDnsRulesDialog
import com.frostnerd.smokescreen.dialog.HostSourceRefreshDialog
import com.frostnerd.smokescreen.dialog.NewHostSourceDialog
import com.frostnerd.smokescreen.service.RuleExportService
import com.frostnerd.smokescreen.service.RuleImportService
import com.frostnerd.smokescreen.util.SpaceItemDecorator
import com.frostnerd.smokescreen.util.worker.RuleImportStartWorker
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_dns_rules.*
import kotlinx.android.synthetic.main.dialog_host_source_refresh.*
import kotlinx.android.synthetic.main.item_datasource.view.*
import kotlinx.android.synthetic.main.item_datasource.view.cardContent
import kotlinx.android.synthetic.main.item_datasource.view.delete
......@@ -39,6 +46,8 @@ import kotlinx.android.synthetic.main.item_dnsrule_host.view.*
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.time.Duration
import java.util.concurrent.TimeUnit
/*
* Copyright (C) 2019 Daniel Wolf (Ch4t4r)
......@@ -118,13 +127,46 @@ class DnsRuleFragment : Fragment() {
}).show()
}
refresh.setOnClickListener {
if(context!!.isServiceRunning(RuleImportService::class.java)) {
context!!.startService(Intent(context!!, RuleImportService::class.java).putExtra("abort", true))
} else {
context!!.startService(Intent(context!!, RuleImportService::class.java))
refreshProgress.show()
refreshProgressShown = true
}
HostSourceRefreshDialog(context!!,runRefresh = {
if(context!!.isServiceRunning(RuleImportService::class.java)) {
context!!.startService(Intent(context!!, RuleImportService::class.java).putExtra("abort", true))
} else {
context!!.startService(Intent(context!!, RuleImportService::class.java))
refreshProgress.show()
refreshProgressShown = true
}
}, refreshConfigChanged = {
getPreferences().apply {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiresBatteryNotLow(true)
.setRequiredNetworkType(if (this.automaticHostRefreshWifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
val mappedTimeAmount = automaticHostRefreshTimeAmount.let {
if (automaticHostRefreshTimeUnit == HostSourceRefreshDialog.TimeUnit.WEEKS) it * 7
else it
}.toLong()
val mappedTimeUnit = automaticHostRefreshTimeUnit.let {
when (it) {
HostSourceRefreshDialog.TimeUnit.WEEKS -> TimeUnit.DAYS
HostSourceRefreshDialog.TimeUnit.DAYS -> TimeUnit.DAYS
HostSourceRefreshDialog.TimeUnit.HOURS -> TimeUnit.HOURS
HostSourceRefreshDialog.TimeUnit.MINUTES -> TimeUnit.MINUTES
}
}
val workRequest = PeriodicWorkRequest.Builder(RuleImportStartWorker::class.java,
mappedTimeAmount,
mappedTimeUnit)
.setConstraints(constraints)
.setInitialDelay(mappedTimeAmount, mappedTimeUnit)
.addTag("hostSourceRefresh")
WorkManager.getInstance(context!!).apply {
cancelAllWorkByTag("hostSourceRefresh")
enqueue(workRequest.build())
}
}
}).show()
}
export.setOnClickListener {
if (context!!.isServiceRunning(RuleExportService::class.java)) {
......
......@@ -3,6 +3,7 @@ package com.frostnerd.smokescreen.service
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
......@@ -94,6 +95,8 @@ class DnsVpnService : VpnService(), Runnable {
private var queryCountOffset: Long = 0
private var packageBypassAmount = 0
private var connectedToANetwork:Boolean? = null
private var lastScreenOff:Long? = null
private lateinit var screenStateReceiver:BroadcastReceiver
/*
URLs passed to the Service, which haven't been retrieved from the settings.
......@@ -185,6 +188,15 @@ class DnsVpnService : VpnService(), Runnable {
updateServiceTile()
subscribeToSettings()
addNetworkChangeListener()
screenStateReceiver = registerReceiver(listOf(Intent.ACTION_SCREEN_OFF, Intent.ACTION_SCREEN_ON)) {
if(it?.action == Intent.ACTION_SCREEN_OFF) {
lastScreenOff = System.currentTimeMillis()
} else {
if(lastScreenOff != null && System.currentTimeMillis() - lastScreenOff!! >= 60000) {
if(fileDescriptor != null) recreateVpn(false, null)
}
}
}
log("Service created.")
}
......@@ -513,9 +525,14 @@ class DnsVpnService : VpnService(), Runnable {
queryCountOffset += currentTrafficStats?.packetsReceivedFromDevice ?: 0
vpnProxy?.stop()
fileDescriptor?.close()
if(isStoppingCompletely && networkCallback != null) {
(getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).unregisterNetworkCallback(networkCallback)
networkCallback = null
if (isStoppingCompletely) {
if (networkCallback != null) {
(getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).unregisterNetworkCallback(
networkCallback
)
networkCallback = null
}
unregisterReceiver(screenStateReceiver)
}
vpnProxy = null
fileDescriptor = null
......
......@@ -166,6 +166,7 @@ class RuleImportService : IntentService("RuleImportService") {
dnsRuleDao.deleteStagedRules()
var count = 0
val maxCount = getDatabase().hostSourceDao().getEnabledCount()
val newChecksums = mutableMapOf<HostSource, String>()
getDatabase().hostSourceDao().getAllEnabled().forEach {
log("Importing HostSource $it")
if (!isAborted) {
......@@ -189,15 +190,31 @@ class RuleImportService : IntentService("RuleImportService") {
var response: Response? = null
try {
val request = Request.Builder().url(it.source)
if(it.checksum != null) request.header("If-None-Match", it.checksum!!)
response = httpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
processLines(it, response.body!!.byteStream())
} else {
log("Downloading resource of $it failed.")
val localDataIsRecent = response.code == 304 || it.checksum!= null && response.headers.find {
it.first.equals("etag", true)
}?.second == it.checksum
when {
response.isSuccessful && !localDataIsRecent -> {
response.headers.find {
it.first.equals("etag", true)
}?.second?.apply {
newChecksums[it] = this
}
processLines(it, response.body!!.byteStream())
}
response.code == 304 || localDataIsRecent -> {
log("Host source ${it.name} hasn't changed, not updating.")
ruleCount += it.ruleCount ?: 0
dnsRuleDao.unstageRulesOfSource(it.id)
log("Unstaged rules for ${it.name}")
}
else -> log("Downloading resource of ${it.name} failed.")
}
} catch (ex: java.lang.Exception) {
ex.printStackTrace()
log("Downloading resource of $it failed ($ex)")
log("Downloading resource of ${it.name} failed ($ex)")
} finally {
response?.body?.close()
}
......@@ -217,6 +234,12 @@ class RuleImportService : IntentService("RuleImportService") {
dnsRuleDao.deleteStagedRules()
log("Recreating database indices")
getDatabase().recreateDnsRuleIndizes()
log("Updating Etag values for sources")
newChecksums.forEach { (source, etag) ->
source.checksum = etag
getDatabase().hostSourceDao().update(source)
}
getDatabase().hostSourceDao().removeChecksumForDisabled()
log("Done.")
showSuccessNotification()
} else {
......@@ -302,11 +325,12 @@ class RuleImportService : IntentService("RuleImportService") {
val defaultTargetV6 = if(isWhitelist) "" else "1"
when {
matcher.groupCount() == 1 -> {
val host = matcher.group(1).replace(wwwRegex, "")
val host = if(matcher == dnsmasqBlockMatcher) "%%" + matcher.group(1)
else matcher.group(1).replace(wwwRegex, "")
return createRule(host, defaultTargetV4, defaultTargetV6, Record.TYPE.ANY, sourceId)
}
matcher == dnsmasqMatcher -> {
val host = matcher.group(1).replace(wwwRegex, "")
val host = "%%" + matcher.group(1)
var target = matcher.group(2)
val type = if (target.contains(":")) Record.TYPE.AAAA else Record.TYPE.A
target = target.let {
......
......@@ -11,6 +11,7 @@ import com.frostnerd.preferenceskt.typedpreferences.cache.ExpirationCacheControl
import com.frostnerd.preferenceskt.typedpreferences.cache.buildCacheStrategy
import com.frostnerd.preferenceskt.typedpreferences.types.*
import com.frostnerd.smokescreen.BuildConfig
import com.frostnerd.smokescreen.dialog.HostSourceRefreshDialog
import java.util.*
/*
......@@ -229,7 +230,7 @@ class AppSettingsSharedPreferences(context: Context) : AppSettings, SimpleTypedP
shouldContain(BuildConfig.APPLICATION_ID)
}, cacheControl)
override var dnsServerConfig: DnsServerInformation<*> by cache(DnsServerInformationPreference("dns_server_config") {
AbstractTLSDnsHandle.waitUntilKnownServersArePopulated(500) { knownServers ->
AbstractTLSDnsHandle.waitUntilKnownServersArePopulated(-1) { knownServers ->
knownServers.getValue(9)
}
}, cacheControl)
......@@ -243,6 +244,11 @@ class AppSettingsSharedPreferences(context: Context) : AppSettings, SimpleTypedP
var vpnServiceState:VpnServiceState by enumPref("vpn_service_state", VpnServiceState.STOPPED)
var ignoreServiceKilled:Boolean by booleanPref("ignore_service_killed", false)
var automaticHostRefresh:Boolean by booleanPref("automatic_host_refresh", false)
var automaticHostRefreshWifiOnly:Boolean by booleanPref("automatic_host_refresh_wifi_only", true)
var automaticHostRefreshTimeUnit:HostSourceRefreshDialog.TimeUnit by enumPref("automatic_host_refresh_timeunit", HostSourceRefreshDialog.TimeUnit.HOURS)
var automaticHostRefreshTimeAmount:Int by intPref("automatic_host_refresh_timeamount", 12)
}
fun AppSettings.Companion.fromSharedPreferences(context: Context): AppSettingsSharedPreferences {
......
......@@ -54,7 +54,9 @@ class DnsServerInformationPreference(key: String, defaultValue: (String) -> DnsS
}
} else {
defaultValue(key)
defaultValue(key).also {
setValue(thisRef, property, it)
}
}
}
......
......@@ -48,8 +48,10 @@ class ProxyTlsHandler(
) {
val destination = selectAddressOrNull(realDestination)
if(destination != null) {
val data = dnsMessage.toArray()
sendPacketToUpstreamDNSServer(deviceWriteToken, DatagramPacket(data, 0, data.size, destination, realDestination.port), originalEnvelope)
if(dnsMessage.questions.all { it.type != null }) {
val data = dnsMessage.toArray()
sendPacketToUpstreamDNSServer(deviceWriteToken, DatagramPacket(data, 0, data.size, destination, realDestination.port), originalEnvelope)
}
} else {
val response = dnsMessage.asBuilder().setQrFlag(true).setResponseCode(DnsMessage.RESPONSE_CODE.SERVER_FAIL)
dnsPacketProxy?.tunnelHandle?.proxy?.logger?.warning("Cannot forward packet because the address isn't resolved yet.")
......
......@@ -61,7 +61,7 @@ class QueryListener(private val context: Context) : QueryListener {
if (writeQueriesToLog) {
context.log("Query from device: $questionMessage")
}
if (logQueriesToDb) {
if (logQueriesToDb && questionMessage.questions.size != 0) {
val query = DnsQuery(
type = questionMessage.question.type,
name = questionMessage.question.name.toString(),
......
package com.frostnerd.smokescreen.util.worker
import android.content.Context
import android.content.Intent
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.frostnerd.smokescreen.service.RuleImportService
import com.frostnerd.smokescreen.startForegroundServiceCompat
/*
* Copyright (C) 2019 Daniel Wolf (Ch4t4r)
*
* 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 <http://www.gnu.org/licenses/>.
*
* You can contact the developer at daniel.wolf@frostnerd.com.
*/
class RuleImportStartWorker(appContext: Context, workerParams: WorkerParameters)
: Worker(appContext, workerParams) {
override fun doWork(): Result {
applicationContext.startForegroundServiceCompat(Intent(applicationContext, RuleImportService::class.java))
return Result.success()
}
}
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
android:layout_width="match_parent"
android:paddingLeft="@dimen/dialog_horizontal_margin"
android:paddingRight="@dimen/dialog_horizontal_margin"
android:paddingTop="@dimen/dialog_vertical_margin"
android:paddingBottom="@dimen/dialog_vertical_margin"
android:layout_height="match_parent">
<CheckBox
android:layout_width="wrap_content"
android:text="@string/dialog_hostsourcerefresh_automatic_refresh"
android:textColor="?android:attr/textColor"
android:id="@+id/automaticRefresh"
android:layout_height="wrap_content"/>
<CheckBox
android:layout_width="wrap_content"
android:text="@string/dialog_hostsourcerefresh_wifi_only"
android:textColor="?android:attr/textColor"
android:id="@+id/refreshWifiOnly"
android:layout_height="wrap_content"/>
<LinearLayout
android:layout_width="match_parent"
android:id="@+id/refreshTimeWrap"
android:orientation="vertical"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/dialog_hostsourcerefresh_refresh_every"
android:layout_height="wrap_content"/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="wrap_content"
android:id="@+id/timeAmountTil"
android:layout_alignParentTop="true"
android:minWidth="48dp"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:id="@+id/timeAmount"
android:inputType="numberSigned"
android:imeOptions="actionNext"
android:text="12"
android:maxLines="1"
android:layout_height="wrap_content"/>
</com.google.android.material.textfield.TextInputLayout>
<Spinner
android:layout_width="wrap_content"
android:layout_alignParentEnd="true"
android:layout_toEndOf="@id/timeAmountTil"
android:id="@+id/timeUnit"
android:layout_marginStart="8dp"
android:layout_alignBaseline="@id/timeAmountTil"
android:layout_height="wrap_content"/>
</RelativeLayout>
</LinearLayout>
<Button
android:layout_width="match_parent"
android:background="@drawable/main_roundbuttons"
android:id="@+id/refreshNow"
android:layout_marginTop="16dp"
android:text="@string/dialog_hostsourcerefresh_refresh_now"
style="@style/Base.Widget.AppCompat.Button.Borderless.Colored"
android:layout_height="wrap_content"/>