Commit 4fa62d5e authored by Daniel Wolf's avatar Daniel Wolf
Browse files

Merge branch '94-overhaul-the-query-log-view' into 'master'

Resolve "Overhaul the query log view"

Closes #94

See merge request PublicAndroidApps/smokescreen!38
parents cabf80ee 531ab0ee
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "6593a50157ce88fb018043b5c151353a",
"entities": [
{
"tableName": "CachedResponse",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`dnsName` TEXT NOT NULL, `type` INTEGER NOT NULL, `records` TEXT NOT NULL, PRIMARY KEY(`dnsName`, `type`))",
"fields": [
{
"fieldPath": "dnsName",
"columnName": "dnsName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "records",
"columnName": "records",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"dnsName",
"type"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DnsQuery",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `askedServer` TEXT, `responseSource` TEXT NOT NULL, `questionTime` INTEGER NOT NULL, `responseTime` INTEGER NOT NULL, `responses` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "askedServer",
"columnName": "askedServer",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "responseSource",
"columnName": "responseSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "questionTime",
"columnName": "questionTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "responseTime",
"columnName": "responseTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "responses",
"columnName": "responses",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DnsRule",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stagingType` INTEGER NOT NULL, `type` INTEGER NOT NULL, `host` TEXT NOT NULL, `target` TEXT NOT NULL, `ipv6Target` TEXT, `importedFrom` INTEGER, `isWildcard` INTEGER NOT NULL, FOREIGN KEY(`importedFrom`) REFERENCES `HostSource`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stagingType",
"columnName": "stagingType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "target",
"columnName": "target",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ipv6Target",
"columnName": "ipv6Target",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "importedFrom",
"columnName": "importedFrom",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isWildcard",
"columnName": "isWildcard",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_DnsRule_importedFrom",
"unique": false,
"columnNames": [
"importedFrom"
],
"createSql": "CREATE INDEX `index_DnsRule_importedFrom` ON `${TABLE_NAME}` (`importedFrom`)"
},
{
"name": "index_DnsRule_host_type_stagingType",
"unique": true,
"columnNames": [
"host",
"type",
"stagingType"
],
"createSql": "CREATE UNIQUE INDEX `index_DnsRule_host_type_stagingType` ON `${TABLE_NAME}` (`host`, `type`, `stagingType`)"
}
],
"foreignKeys": [
{
"table": "HostSource",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"importedFrom"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "HostSource",
"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",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ruleCount",
"columnName": "ruleCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "checksum",
"columnName": "checksum",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "whitelistSource",
"columnName": "whitelistSource",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"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, '6593a50157ce88fb018043b5c151353a')"
]
}
}
\ No newline at end of file
......@@ -2,6 +2,7 @@ package com.frostnerd.smokescreen.activity
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.frostnerd.dnstunnelproxy.QueryListener
import com.frostnerd.smokescreen.R
import com.frostnerd.smokescreen.database.converters.StringListConverter
import com.frostnerd.smokescreen.database.entities.DnsQuery
......@@ -55,11 +56,17 @@ class QueryImportActivity: AppCompatActivity() {
val converter = StringListConverter()
for(line in iterator) {
val split = line.split(",")
val source = if(split[5].equals("false", true) || split[5].equals("true", true)) {
if (split[5].toBoolean()) QueryListener.Source.CACHE
else QueryListener.Source.UPSTREAM
} else QueryListener.Source.values().find {
it.name.equals(split[5], true)
} ?: QueryListener.Source.UPSTREAM
queries.add(DnsQuery(
name=split[0],
type = Record.TYPE.getType(split[3].toInt()),
askedServer = split[4],
fromCache = split[5].toBoolean(),
responseSource = source,
questionTime = split[6].toLong(),
responseTime = split[7].toLong(),
responses = converter.stringToList(split[8].replaceFirst("\"", "").replace(Regex("\"$"), ""))
......
......@@ -37,7 +37,7 @@ import com.frostnerd.smokescreen.database.repository.HostSourceRepository
@Database(entities = [CachedResponse::class, DnsQuery::class, DnsRule::class, HostSource::class], version = AppDatabase.currentVersion)
abstract class AppDatabase : RoomDatabase() {
companion object {
const val currentVersion:Int = 10
const val currentVersion:Int = 11
}
abstract fun cachedResponseDao(): CachedResponseDao
......
......@@ -97,12 +97,21 @@ val MIGRATION_9_10 = migration(9, 10) {
Logger.logIfOpen("DB_MIGRATION", "Migration from 9 to 10 completed")
}
val MIGRATION_10_11 = migration(10, 11) {
Logger.logIfOpen("DB_MIGRATION", "Migrating from 10 to 11")
it.execSQL("CREATE TABLE `DnsQuery_tmp` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `askedServer` TEXT, `responseSource` TEXT NOT NULL, `questionTime` INTEGER NOT NULL, `responseTime` INTEGER NOT NULL, `responses` TEXT NOT NULL)")
it.execSQL("INSERT INTO `DnsQuery_tmp`(id, type, name, askedServer, questionTime, responseTime, responses, responseSource) SELECT id, type, name, askedServer, questionTime, responseTime, responses, CASE WHEN fromCache=1 THEN 'CACHE' else 'UPSTREAM' END as `responseSource` FROM DnsQuery")
it.execSQL("DROP TABLE `DnsQuery`")
it.execSQL("ALTER TABLE `DnsQuery_tmp` RENAME TO `DnsQuery`")
Logger.logIfOpen("DB_MIGRATION", "Migration from 10 to 11 completed")
}
fun Context.getDatabase(): AppDatabase {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(applicationContext, AppDatabase::class.java, "data")
.allowMainThreadQueries()
.addMigrations(MIGRATION_2_X, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
.addMigrations(MIGRATION_2_X, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11)
.build()
}
return INSTANCE!!
......
package com.frostnerd.smokescreen.database.converters
import androidx.room.TypeConverter
import com.frostnerd.dnstunnelproxy.QueryListener
/*
* 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 QuerySourceConverter {
@TypeConverter
fun sourceFromString(value: String): QueryListener.Source? {
return QueryListener.Source.values().find {
it.name.equals(value, true)
}
}
@TypeConverter
fun sourceToString(source:QueryListener.Source?): String {
return source?.name ?: ""
}
}
\ No newline at end of file
......@@ -5,7 +5,9 @@ import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.frostnerd.dnstunnelproxy.QueryListener
import com.frostnerd.smokescreen.database.converters.DnsTypeConverter
import com.frostnerd.smokescreen.database.converters.QuerySourceConverter
import com.frostnerd.smokescreen.database.converters.StringListConverter
import com.frostnerd.smokescreen.database.recordFromBase64
import org.minidns.record.Record
......@@ -29,13 +31,13 @@ import org.minidns.record.Record
* You can contact the developer at daniel.wolf@frostnerd.com.
*/
@Entity(tableName = "DnsQuery")
@TypeConverters(DnsTypeConverter::class, StringListConverter::class)
@TypeConverters(DnsTypeConverter::class, StringListConverter::class, QuerySourceConverter::class)
data class DnsQuery(
@PrimaryKey(autoGenerate = true) var id: Long = 0,
val type: Record.TYPE,
val name: String,
var askedServer: String?,
var fromCache:Boolean,
var responseSource:QueryListener.Source,
val questionTime: Long,
var responseTime: Long = 0,
var responses: MutableList<String>
......
......@@ -86,7 +86,7 @@ class DnsQueryRepository(private val dnsQueryDao: DnsQueryDao) {
builder.append(query.type.name).append(",")
builder.append(query.type.value).append(",")
builder.append(query.askedServer).append(",")
builder.append(query.fromCache).append(",")
builder.append(query.responseSource?.name ?: "").append(",")
builder.append(query.questionTime).append(",")
builder.append(query.responseTime).append(",")
builder.append("\"").append(responseConverter.someObjectListToString(query.responses).replace(",", ";").replace("\"", "'")).append("\"")
......
package com.frostnerd.smokescreen.fragment.querylogfragment
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.frostnerd.dnstunnelproxy.QueryListener
import com.frostnerd.smokescreen.R
import com.frostnerd.smokescreen.database.entities.DnsQuery
import com.frostnerd.smokescreen.database.entities.DnsRule
import com.frostnerd.smokescreen.database.getDatabase
import com.frostnerd.smokescreen.dialog.DnsRuleDialog
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_dns_rules.*
import kotlinx.android.synthetic.main.fragment_querylog_detail.*
import org.minidns.record.A
import org.minidns.record.Record
import java.text.DateFormat
import java.util.*
/*
......@@ -31,17 +42,76 @@ import kotlinx.android.synthetic.main.fragment_querylog_detail.*
class QueryLogDetailFragment : Fragment() {
var currentQuery: DnsQuery? = null
private set
private lateinit var timeFormatSameDay: DateFormat
private lateinit var timeFormatDifferentDay: DateFormat
private fun formatTimeStamp(timestamp:Long): String {
return if(isTimeStampToday(timestamp)) timeFormatSameDay.format(timestamp)
else timeFormatDifferentDay.format(timestamp)
}
private fun isTimeStampToday(timestamp:Long):Boolean {
return timestamp >= getStartOfDay()
}
private fun getStartOfDay():Long {
val calendar = Calendar.getInstance()
calendar.set(Calendar.HOUR_OF_DAY, 0)
calendar.set(Calendar.MINUTE, 0)
calendar.set(Calendar.SECOND, 0)
calendar.set(Calendar.MILLISECOND, 0)
return calendar.timeInMillis
}
private fun getLocale(): Locale {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
resources.configuration.locales.get(0)!!
} else{
@Suppress("DEPRECATION")
resources.configuration.locale!!
}
}
private fun setupTimeFormat() {
val locale = getLocale()
timeFormatSameDay = DateFormat.getTimeInstance(DateFormat.MEDIUM, locale)
timeFormatDifferentDay = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM, locale)
}
private var viewCreated = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
setupTimeFormat()
return inflater.inflate(R.layout.fragment_querylog_detail, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewCreated = true
updateUi()
createDnsRule.setOnClickListener {
val query = currentQuery
if(query != null) {
val answerIP = query.getParsedResponses().firstOrNull {
it.type == query.type
}?.payload?.toString() ?: if(query.type == Record.TYPE.A) "0.0.0.0" else "::1"
DnsRuleDialog(context!!, DnsRule(query.type, query.name, target = answerIP), onRuleCreated = { newRule ->
val id = if (newRule.isWhitelistRule()) {
getDatabase().dnsRuleDao().insertWhitelist(newRule)
} else getDatabase().dnsRuleDao().insertIgnore(newRule)
if (id != -1L) {
Snackbar.make(
activity!!.findViewById(android.R.id.content),
R.string.windows_querylogging_dnsrule_created,
Snackbar.LENGTH_LONG
).show()
} else {
Snackbar.make(
activity!!.findViewById(android.R.id.content),
R.string.window_dnsrules_hostalreadyexists,
Snackbar.LENGTH_LONG
).show()
}
}).show()
}
}
}
fun isShowingQuery(): Boolean {
......@@ -60,7 +130,7 @@ class QueryLogDetailFragment : Fragment() {
private fun updateUi() {
val query = currentQuery
if(query != null && viewCreated) {
queryTime.text = QueryLogListFragment.formatTimeStamp(query.questionTime)
queryTime.text = formatTimeStamp(query.questionTime)
if(query.responseTime >= query.questionTime) {
latency.text = (query.responseTime - query.questionTime).toString() + " ms"
} else {
......@@ -69,14 +139,25 @@ class QueryLogDetailFragment : Fragment() {
longName.text = query.name
type.text = query.type.name
protocol.text = when {
query.askedServer == null -> ""
query.responseSource == QueryListener.Source.CACHE -> getString(R.string.windows_querylogging_usedserver_cache)
query.responseSource == QueryListener.Source.LOCALRESOLVER -> getString(R.string.windows_querylogging_usedserver_dnsrules)
query.askedServer == null -> "-"
query.askedServer!!.startsWith("https") -> getString(R.string.fragment_querydetail_mode_doh)
else -> getString(R.string.fragment_querydetail_mode_dot)
}
if(query.fromCache) {
resolvedBy.text = "Cache"
} else {
resolvedBy.text = query.askedServer?.replace("tls::", "")?.replace("https::", "") ?: "-"
resolvedBy.text = when {
query.responseSource == QueryListener.Source.CACHE -> getString(R.string.windows_querylogging_usedserver_cache)
query.responseSource == QueryListener.Source.LOCALRESOLVER -> getString(R.string.windows_querylogging_usedserver_dnsrules)
else -> query.askedServer?.replace("tls::", "")?.replace("https::", "") ?: "-"
}
responses.text = query.getParsedResponses().filter {
it.type == query.type
}.joinToString {
val payload = it.payload
payload.toString()
}.let {
if(it.isBlank()) "-"
else it
}
}
}
......
......@@ -9,12 +9,14 @@ import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.frostnerd.cacheadapter.ModelAdapterBuilder
import com.frostnerd.dnstunnelproxy.QueryListener
import com.frostnerd.smokescreen.R
import com.frostnerd.smokescreen.database.entities.DnsQuery
import com.frostnerd.smokescreen.database.getDatabase
import com.frostnerd.smokescreen.fragment.QueryLogFragment
import com.frostnerd.smokescreen.util.LiveDataSource
import kotlinx.android.synthetic.main.fragment_querylog_list.*
import kotlinx.android.synthetic.main.item_logged_query.view.*
import java.text.DateFormat
import java.util.*
......@@ -37,39 +39,13 @@ import java.util.*
* You can contact the developer at daniel.wolf@frostnerd.com.
*/
class QueryLogListFragment: Fragment() {
companion object {
internal lateinit var timeFormatSameDay: DateFormat
internal lateinit var timeFormatDifferentDay: DateFormat
internal fun formatTimeStamp(timestamp:Long): String {
return if(isTimeStampToday(timestamp)) timeFormatSameDay.format(timestamp)
else timeFormatDifferentDay.format(timestamp)
}
private fun isTimeStampToday(timestamp:Long):Boolean {