Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-20 14:05:12 +01:00
parent 6e9a0d01ce
commit 7ee9806fba
2415 changed files with 312708 additions and 2 deletions

36
app/.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
#Android specific
bin
gen
obj
libs/armeabi
libs/armeabi-v7a
libs/arm64-v8a
libs/mips
libs/mips64
libs/x86
libs/x86_64
local.properties
release.properties
ant.properties
*.class
*.apk
#Gradle
.gradle
build
gradle.properties
#Maven
target
pom.xml.*
#Eclipse
.project
.classpath
.settings
.metadata
#IntelliJ IDEA
.idea
*.iml
/.externalNativeBuild/

149
app/build.gradle Normal file
View file

@ -0,0 +1,149 @@
plugins {
id 'com.android.application'
}
boolean keyStoreDefined = project.hasProperty('signingStoreLocation') &&
project.hasProperty('signingStorePassword') &&
project.hasProperty('signingKeyAlias') &&
project.hasProperty('signingKeyPassword')
repositories {
maven {
url 'https://jitpack.io'
}
}
android {
compileSdk 34
ndkVersion '25.2.9519653'
namespace 'org.adaway'
defaultConfig {
minSdk 26
targetSdk 33
versionCode libs.versions.appCode.get() as int
versionName libs.versions.appName.get()
javaCompileOptions {
annotationProcessorOptions {
arguments = [
"room.schemaLocation": "$projectDir/schemas".toString(),
"room.incremental" : "true"
]
}
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
/*
* To sign release build, create file gradle.properties in ~/.gradle/ with this content:
*
* signingStoreLocation=/home/key.store
* signingStorePassword=xxx
* signingKeyAlias=alias
* signingKeyPassword=xxx
*/
if (keyStoreDefined) {
println "Found signature properties in gradle.properties. Build will be signed."
signingConfigs {
release {
storeFile file(signingStoreLocation)
storePassword signingStorePassword
keyAlias signingKeyAlias
keyPassword signingKeyPassword
}
}
buildTypes.debug.signingConfig = signingConfigs.release
buildTypes.release.signingConfig = signingConfigs.release
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
viewBinding true
}
packagingOptions {
jniLibs {
useLegacyPackaging = true
}
}
buildTypes {
// debug {
// shrinkResources false
// minifyEnabled false
// proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// }
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
// Do not abort build if lint finds errors
lint {
disable 'MissingTranslation'
}
}
dependencies {
// Native modules
implementation project(':tcpdump')
implementation project(':webserver')
// AndroidX components
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.fragment:fragment:1.8.1'
// "fragment-ktx" is not used but was added to fix the following dependency error:
// Duplicate class androidx.lifecycle.ViewModelLazy found in modules lifecycle-viewmodel-2.5.0-runtime (androidx.lifecycle:lifecycle-viewmodel:2.5.0) and lifecycle-viewmodel-ktx-2.3.1-runtime
implementation 'androidx.fragment:fragment-ktx:1.8.1'
implementation 'androidx.paging:paging-runtime:3.3.0'
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-paging:2.6.1'
annotationProcessor 'androidx.room:room-compiler:2.6.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.work:work-runtime:2.8.1'
implementation 'com.google.android.material:material:1.9.0'
// Collections related
implementation libs.guava
// Network related
implementation libs.okhttp3.okhttp
// Logging related
implementation libs.timber
if (keyStoreDefined) {
implementation project(':sentrystub')
} else {
implementation platform('io.sentry:sentry-bom:7.8.0')
implementation('io.sentry:sentry-android')
implementation('io.sentry:sentry-android-fragment')
implementation('io.sentry:sentry-android-timber')
}
// Root related
implementation libs.libsu
// VPN related
implementation libs.bundles.pcap4j
implementation libs.dnsjava
implementation libs.slf4j.android
implementation libs.okhttp.dnsoverhttps
// Test related
testImplementation libs.junit
testImplementation libs.json
androidTestImplementation libs.bundles.androidx.test
androidTestImplementation libs.junit
}

10
app/lint.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- list of issues to configure -->
<issue id="InvalidPackage">
<!-- Ignore dnsjava -->
<ignore path="**/dnsjava-3.0.2*.jar" />
<!-- Ignore pcap4j dependency -->
<ignore path="**/jna-5.3.1.jar"/>
</issue>
</lint>

38
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,38 @@
-keep public class * extends android.content.ContentProvider
# Temporary fix for androidx preference fragement reference
# See https://issuetracker.google.com/issues/145316223
-keep public class org.adaway.ui.prefs.PrefsBackupRestoreFragment
-keep public class org.adaway.ui.prefs.PrefsRootFragment
-keep public class org.adaway.ui.prefs.PrefsUpdateFragment
-keep public class org.adaway.ui.prefs.PrefsVpnFragment
-keepclassmembers class io.sentry.Sentry {
public static final boolean STUB;
}
-dontobfuscate
### Android Jetpack ###
-dontwarn com.google.**
### Sentry ###
-dontwarn io.sentry.**
### OkHttp ###
# JSR 305 annotations are for embedding nullability information.
-dontwarn javax.annotation.**
# A resource is loaded with a relative path so the package of this class must be preserved.
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*
# OkHttp platform used only on JVM and when Conscrypt dependency is available.
-dontwarn okhttp3.internal.platform.ConscryptPlatform
# Generated rules from R8
-dontwarn org.bouncycastle.jsse.**
-dontwarn org.conscrypt.**
-dontwarn org.openjsse.**
### dnsjava ###
-dontwarn lombok.Generated
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor

View file

@ -0,0 +1,90 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "5175df445bc75bbbb5ea672750d7b425",
"entities": [
{
"tableName": "hosts_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_modified_local` INTEGER, `last_modified_online` INTEGER, PRIMARY KEY(`url`))",
"fields": [
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastLocalModification",
"columnName": "last_modified_local",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastOnlineModification",
"columnName": "last_modified_online",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"url"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "hosts_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `type` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `redirection` TEXT, PRIMARY KEY(`host`))",
"fields": [
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"host"
],
"autoGenerate": false
},
"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, \"5175df445bc75bbbb5ea672750d7b425\")"
]
}
}

View file

@ -0,0 +1,146 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "e9b86296a34de1d881f8530fdf2c535d",
"entities": [
{
"tableName": "hosts_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_modified_local` INTEGER, `last_modified_online` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastLocalModification",
"columnName": "last_modified_local",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastOnlineModification",
"columnName": "last_modified_online",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_sources_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_hosts_sources_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": []
},
{
"tableName": "hosts_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `host` TEXT NOT NULL, `type` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `redirection` TEXT, `source_id` INTEGER NOT NULL, FOREIGN KEY(`source_id`) REFERENCES `hosts_sources`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sourceId",
"columnName": "source_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_lists_host",
"unique": true,
"columnNames": [
"host"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_hosts_lists_host` ON `${TABLE_NAME}` (`host`)"
},
{
"name": "index_hosts_lists_source_id",
"unique": false,
"columnNames": [
"source_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_source_id` ON `${TABLE_NAME}` (`source_id`)"
}
],
"foreignKeys": [
{
"table": "hosts_sources",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"source_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"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, 'e9b86296a34de1d881f8530fdf2c535d')"
]
}
}

View file

@ -0,0 +1,151 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "ace31b365ff79d4497319c74157538d7",
"entities": [
{
"tableName": "hosts_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_modified_local` INTEGER, `last_modified_online` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastLocalModification",
"columnName": "last_modified_local",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastOnlineModification",
"columnName": "last_modified_online",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_sources_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_hosts_sources_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": []
},
{
"tableName": "hosts_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `host` TEXT NOT NULL, `type` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `redirection` TEXT, `source_id` INTEGER NOT NULL, FOREIGN KEY(`source_id`) REFERENCES `hosts_sources`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sourceId",
"columnName": "source_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_lists_host",
"unique": false,
"columnNames": [
"host"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_host` ON `${TABLE_NAME}` (`host`)"
},
{
"name": "index_hosts_lists_source_id",
"unique": false,
"columnNames": [
"source_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_source_id` ON `${TABLE_NAME}` (`source_id`)"
}
],
"foreignKeys": [
{
"table": "hosts_sources",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"source_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [
{
"viewName": "host_entries",
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT `host`, `type`, `redirection` FROM `hosts_lists` WHERE `enabled` = 1 AND ((`type` = 0 AND `host` NOT LIKE (SELECT `host` FROM `hosts_lists` WHERE `enabled` = 1 and `type` = 1)) OR `type` = 2) ORDER BY `host` ASC, `type` DESC, `redirection` ASC"
}
],
"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, 'ace31b365ff79d4497319c74157538d7')"
]
}
}

View file

@ -0,0 +1,187 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "80b1c1d47fbd109a4f052817c9faf980",
"entities": [
{
"tableName": "hosts_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_modified_local` INTEGER, `last_modified_online` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localModificationDate",
"columnName": "last_modified_local",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "onlineModificationDate",
"columnName": "last_modified_online",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_sources_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_hosts_sources_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": []
},
{
"tableName": "hosts_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `host` TEXT NOT NULL, `type` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `redirection` TEXT, `source_id` INTEGER NOT NULL, FOREIGN KEY(`source_id`) REFERENCES `hosts_sources`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sourceId",
"columnName": "source_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_lists_host",
"unique": false,
"columnNames": [
"host"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_host` ON `${TABLE_NAME}` (`host`)"
},
{
"name": "index_hosts_lists_source_id",
"unique": false,
"columnNames": [
"source_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_source_id` ON `${TABLE_NAME}` (`source_id`)"
}
],
"foreignKeys": [
{
"table": "hosts_sources",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"source_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "host_entries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `type` INTEGER NOT NULL, `redirection` TEXT, PRIMARY KEY(`host`))",
"fields": [
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"host"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_host_entries_host",
"unique": true,
"columnNames": [
"host"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_host_entries_host` ON `${TABLE_NAME}` (`host`)"
}
],
"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, '80b1c1d47fbd109a4f052817c9faf980')"
]
}
}

View file

@ -0,0 +1,187 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "80b1c1d47fbd109a4f052817c9faf980",
"entities": [
{
"tableName": "hosts_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_modified_local` INTEGER, `last_modified_online` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localModificationDate",
"columnName": "last_modified_local",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "onlineModificationDate",
"columnName": "last_modified_online",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_sources_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_hosts_sources_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": []
},
{
"tableName": "hosts_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `host` TEXT NOT NULL, `type` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `redirection` TEXT, `source_id` INTEGER NOT NULL, FOREIGN KEY(`source_id`) REFERENCES `hosts_sources`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sourceId",
"columnName": "source_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_lists_host",
"unique": false,
"columnNames": [
"host"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_host` ON `${TABLE_NAME}` (`host`)"
},
{
"name": "index_hosts_lists_source_id",
"unique": false,
"columnNames": [
"source_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_source_id` ON `${TABLE_NAME}` (`source_id`)"
}
],
"foreignKeys": [
{
"table": "hosts_sources",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"source_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "host_entries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `type` INTEGER NOT NULL, `redirection` TEXT, PRIMARY KEY(`host`))",
"fields": [
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"host"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_host_entries_host",
"unique": true,
"columnNames": [
"host"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_host_entries_host` ON `${TABLE_NAME}` (`host`)"
}
],
"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, '80b1c1d47fbd109a4f052817c9faf980')"
]
}
}

View file

@ -0,0 +1,211 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "c53f309b3cbcdeda90c9f22573023ac2",
"entities": [
{
"tableName": "hosts_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `url` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `allowEnabled` INTEGER NOT NULL, `redirectEnabled` INTEGER NOT NULL, `last_modified_local` INTEGER, `last_modified_online` INTEGER, `size` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "allowEnabled",
"columnName": "allowEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirectEnabled",
"columnName": "redirectEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localModificationDate",
"columnName": "last_modified_local",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "onlineModificationDate",
"columnName": "last_modified_online",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_sources_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_hosts_sources_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": []
},
{
"tableName": "hosts_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `host` TEXT NOT NULL, `type` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `redirection` TEXT, `source_id` INTEGER NOT NULL, FOREIGN KEY(`source_id`) REFERENCES `hosts_sources`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sourceId",
"columnName": "source_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_lists_host",
"unique": false,
"columnNames": [
"host"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_host` ON `${TABLE_NAME}` (`host`)"
},
{
"name": "index_hosts_lists_source_id",
"unique": false,
"columnNames": [
"source_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_source_id` ON `${TABLE_NAME}` (`source_id`)"
}
],
"foreignKeys": [
{
"table": "hosts_sources",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"source_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "host_entries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `type` INTEGER NOT NULL, `redirection` TEXT, PRIMARY KEY(`host`))",
"fields": [
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"host"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_host_entries_host",
"unique": true,
"columnNames": [
"host"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_host_entries_host` ON `${TABLE_NAME}` (`host`)"
}
],
"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, 'c53f309b3cbcdeda90c9f22573023ac2')"
]
}
}

View file

@ -0,0 +1,221 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "dccd6211bcef97caed75ea42d7df1b32",
"entities": [
{
"tableName": "hosts_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `url` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `allowEnabled` INTEGER NOT NULL, `redirectEnabled` INTEGER NOT NULL, `last_modified_local` INTEGER, `last_modified_online` INTEGER, `entityTag` TEXT, `size` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "allowEnabled",
"columnName": "allowEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirectEnabled",
"columnName": "redirectEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localModificationDate",
"columnName": "last_modified_local",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "onlineModificationDate",
"columnName": "last_modified_online",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "entityTag",
"columnName": "entityTag",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_sources_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_hosts_sources_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": []
},
{
"tableName": "hosts_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `host` TEXT NOT NULL, `type` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `redirection` TEXT, `source_id` INTEGER NOT NULL, FOREIGN KEY(`source_id`) REFERENCES `hosts_sources`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sourceId",
"columnName": "source_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_hosts_lists_host",
"unique": false,
"columnNames": [
"host"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_host` ON `${TABLE_NAME}` (`host`)"
},
{
"name": "index_hosts_lists_source_id",
"unique": false,
"columnNames": [
"source_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_hosts_lists_source_id` ON `${TABLE_NAME}` (`source_id`)"
}
],
"foreignKeys": [
{
"table": "hosts_sources",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"source_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "host_entries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `type` INTEGER NOT NULL, `redirection` TEXT, PRIMARY KEY(`host`))",
"fields": [
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "redirection",
"columnName": "redirection",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"host"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_host_entries_host",
"unique": true,
"columnNames": [
"host"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_host_entries_host` ON `${TABLE_NAME}` (`host`)"
}
],
"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, 'dccd6211bcef97caed75ea42d7df1b32')"
]
}
}

View file

@ -0,0 +1,151 @@
package org.adaway.db;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_URL;
import static org.adaway.db.entity.ListType.ALLOWED;
import static org.adaway.db.entity.ListType.BLOCKED;
import static org.adaway.db.entity.ListType.REDIRECTED;
import static org.junit.Assert.fail;
import android.content.Context;
import androidx.annotation.Nullable;
import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.room.Room;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.adaway.db.dao.HostEntryDao;
import org.adaway.db.dao.HostListItemDao;
import org.adaway.db.dao.HostsSourceDao;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.HostsSource;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.rules.TestRule;
import org.junit.runner.RunWith;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* This class is a base class for testing database feature.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
@RunWith(AndroidJUnit4.class)
public abstract class DbTest {
protected static final int EXTERNAL_SOURCE_ID = 2;
@Rule
public TestRule rule = new InstantTaskExecutorRule();
protected AppDatabase db;
protected HostsSourceDao hostsSourceDao;
protected HostListItemDao hostListItemDao;
protected HostEntryDao hostEntryDao;
protected LiveData<Integer> blockedHostCount;
protected LiveData<Integer> allowedHostCount;
protected LiveData<Integer> redirectedHostCount;
protected HostsSource externalHostSource;
protected static <T> T getOrAwaitValue(final LiveData<T> liveData) throws InterruptedException {
final Object[] data = new Object[1];
final CountDownLatch latch = new CountDownLatch(1);
Observer<T> observer = new Observer<T>() {
@Override
public void onChanged(@Nullable T o) {
data[0] = o;
latch.countDown();
liveData.removeObserver(this);
}
};
liveData.observeForever(observer);
if (!latch.await(2, TimeUnit.SECONDS)) {
fail("Failed to get LiveData value in time");
}
//noinspection unchecked
return (T) data[0];
}
@Before
public void init() {
createDb();
loadDao();
createSources();
}
protected void createDb() {
Context context = ApplicationProvider.getApplicationContext();
this.db = Room.inMemoryDatabaseBuilder(context, AppDatabase.class)
.allowMainThreadQueries()
.build();
}
protected void loadDao() {
this.hostsSourceDao = this.db.hostsSourceDao();
this.hostListItemDao = this.db.hostsListItemDao();
this.hostEntryDao = this.db.hostEntryDao();
this.blockedHostCount = this.hostListItemDao.getBlockedHostCount();
this.allowedHostCount = this.hostListItemDao.getAllowedHostCount();
this.redirectedHostCount = this.hostListItemDao.getRedirectHostCount();
}
protected void createSources() {
// Insert at least user source and external source to allow duplicate hosts to be inserted
insertSource(USER_SOURCE_ID, USER_SOURCE_URL);
insertSource(EXTERNAL_SOURCE_ID, "https://adaway.org/hosts.txt");
this.externalHostSource = getSourceFromId(EXTERNAL_SOURCE_ID);
}
@After
public void closeDb() {
this.db.close();
}
protected void insertSource(int id, String url) {
HostsSource source = new HostsSource();
source.setId(id);
source.setLabel(url);
source.setUrl(url);
source.setEnabled(true);
this.hostsSourceDao.insert(source);
}
protected void insertBlockedHost(String host, int sourceId) {
HostListItem item = new HostListItem();
item.setType(BLOCKED);
item.setHost(host);
item.setEnabled(true);
item.setSourceId(sourceId);
this.hostListItemDao.insert(item);
}
protected void insertAllowedHost(String host, int sourceId) {
HostListItem item = new HostListItem();
item.setType(ALLOWED);
item.setHost(host);
item.setEnabled(true);
item.setSourceId(sourceId);
this.hostListItemDao.insert(item);
}
protected void insertRedirectedHost(String host, String redirection, int sourceId) {
HostListItem item = new HostListItem();
item.setType(REDIRECTED);
item.setHost(host);
item.setEnabled(true);
item.setRedirection(redirection);
item.setSourceId(sourceId);
this.hostListItemDao.insert(item);
}
protected HostsSource getSourceFromId(int id) {
return this.hostsSourceDao.getAll()
.stream()
.filter(hostsSource -> hostsSource.getId() == id)
.findAny()
.orElse(null);
}
}

View file

@ -0,0 +1,90 @@
package org.adaway.db;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import org.adaway.db.entity.HostEntry;
import org.junit.Test;
import java.util.List;
/**
* This class tests {@link org.adaway.db.entity.HostListItem} database manipulations.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class HostDbTest extends DbTest {
@Test
public void testEmptyByDefault() throws InterruptedException {
assertEquals(0, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(0, getOrAwaitValue(this.allowedHostCount).intValue());
assertEquals(0, getOrAwaitValue(this.redirectedHostCount).intValue());
assertEquals(0, this.hostEntryDao.getAll().size());
}
@Test
public void testInsertThenDeleteHosts() throws InterruptedException {
// Insert blocked hosts
insertBlockedHost("advertising.apple.com", USER_SOURCE_ID);
insertBlockedHost("an.facebook.com", USER_SOURCE_ID);
this.hostEntryDao.sync();
// Check inserting
assertEquals(2, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(0, getOrAwaitValue(this.allowedHostCount).intValue());
assertEquals(0, getOrAwaitValue(this.redirectedHostCount).intValue());
assertEquals(2, this.hostEntryDao.getAll().size());
// Remove block hosts
this.hostListItemDao.deleteUserFromHost("advertising.apple.com");
this.hostEntryDao.sync();
// Check deletion
assertEquals(1, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(0, getOrAwaitValue(this.allowedHostCount).intValue());
assertEquals(0, getOrAwaitValue(this.redirectedHostCount).intValue());
assertEquals(1, this.hostEntryDao.getAll().size());
}
@Test
public void testDuplicateBlockedHosts() throws InterruptedException {
insertBlockedHost("advertising.apple.com", USER_SOURCE_ID);
insertBlockedHost("advertising.apple.com", EXTERNAL_SOURCE_ID);
this.hostEntryDao.sync();
assertEquals(1, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(1, this.hostEntryDao.getAll().size());
}
@Test
public void testDuplicateAllowedHosts() throws InterruptedException {
insertAllowedHost("adaway.org", USER_SOURCE_ID);
insertAllowedHost("adaway.org", EXTERNAL_SOURCE_ID);
this.hostEntryDao.sync();
assertEquals(1, getOrAwaitValue(this.allowedHostCount).intValue());
assertEquals(0, this.hostEntryDao.getAll().size());
}
@Test
public void testDuplicateRedirectedHosts() throws InterruptedException {
insertRedirectedHost("github.com", "1.1.1.1", USER_SOURCE_ID);
insertRedirectedHost("github.com", "2.2.2.2", EXTERNAL_SOURCE_ID);
this.hostEntryDao.sync();
assertEquals(1, getOrAwaitValue(this.redirectedHostCount).intValue());
assertEquals(1, this.hostEntryDao.getAll().size());
}
@Test
public void testRedirectionPriority() throws InterruptedException {
// Insert two redirects for the same host
insertRedirectedHost("adaway.org", "1.1.1.1", USER_SOURCE_ID);
insertRedirectedHost("adaway.org", "2.2.2.2", EXTERNAL_SOURCE_ID);
this.hostEntryDao.sync();
// Test inserted redirected hosts
assertEquals(1, getOrAwaitValue(this.redirectedHostCount).intValue());
// Test inserted redirect
List<HostEntry> entries = this.hostEntryDao.getAll();
assertEquals(1, entries.size());
// Test user redirect is applied in priority
HostEntry entry = this.hostEntryDao.getEntry("adaway.org");
assertNotNull(entry);
assertEquals("1.1.1.1", entry.getRedirection());
}
}

View file

@ -0,0 +1,113 @@
package org.adaway.db;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
import static org.junit.Assert.assertEquals;
import org.adaway.db.entity.HostsSource;
import org.junit.Test;
import java.util.List;
/**
* This class tests {@link HostsSource} database manipulations.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class SourceDbTest extends DbTest {
@Test
public void testSourceCount() {
// Test only external source is found
List<HostsSource> sources = this.hostsSourceDao.getAll();
assertEquals(1, sources.size());
assertEquals("https://adaway.org/hosts.txt", sources.get(0).getUrl());
}
@Test
public void testSourceDeletion() throws InterruptedException {
// Insert blocked hosts
insertBlockedHost("bingads.microsoft.com", EXTERNAL_SOURCE_ID);
insertBlockedHost("ads.yahoo.com", EXTERNAL_SOURCE_ID);
this.hostEntryDao.sync();
// Test inserted blocked hosts
assertEquals(2, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(2, this.hostEntryDao.getAll().size());
// Delete source
this.hostsSourceDao.delete(this.externalHostSource);
this.hostEntryDao.sync();
List<HostsSource> sources = this.hostsSourceDao.getAll();
assertEquals(0, sources.size());
// Check related hosts cleaning
assertEquals(0, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(0, this.hostEntryDao.getAll().size());
}
@Test
public void testBlockedHostsFromDisabledSource() throws InterruptedException {
// Insert blocked hosts
insertBlockedHost("advertising.apple.com", USER_SOURCE_ID);
insertBlockedHost("an.facebook.com", USER_SOURCE_ID);
insertBlockedHost("ads.google.com", USER_SOURCE_ID);
insertBlockedHost("bingads.microsoft.com", EXTERNAL_SOURCE_ID);
insertBlockedHost("ads.yahoo.com", EXTERNAL_SOURCE_ID);
this.hostEntryDao.sync();
// Test inserted blocked hosts
assertEquals(5, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(5, this.hostEntryDao.getAll().size());
// Disabled external source
this.hostsSourceDao.toggleEnabled(this.externalHostSource);
this.hostEntryDao.sync();
assertEquals(3, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(3, this.hostEntryDao.getAll().size());
// Re-enable external source
this.hostsSourceDao.toggleEnabled(this.externalHostSource);
this.hostEntryDao.sync();
assertEquals(5, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(5, this.hostEntryDao.getAll().size());
}
@Test
public void testAllowedHostsFromDisabledSource() throws InterruptedException {
// Insert blocked and allowed host
insertBlockedHost("adaway.org", USER_SOURCE_ID);
insertAllowedHost("adaway.org", EXTERNAL_SOURCE_ID);
this.hostEntryDao.sync();
// Test inserted blocked hosts
assertEquals(1, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(1, getOrAwaitValue(this.allowedHostCount).intValue());
assertEquals(0, this.hostEntryDao.getAll().size());
// Disabled a source
this.hostsSourceDao.toggleEnabled(this.externalHostSource);
this.hostEntryDao.sync();
assertEquals(1, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(0, getOrAwaitValue(this.allowedHostCount).intValue());
assertEquals(1, this.hostEntryDao.getAll().size());
// Re-enable a source
this.hostsSourceDao.toggleEnabled(this.externalHostSource);
this.hostEntryDao.sync();
assertEquals(1, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(1, getOrAwaitValue(this.allowedHostCount).intValue());
assertEquals(0, this.hostEntryDao.getAll().size());
}
@Test
public void testRedirectedHostsFromDisabledSource() throws InterruptedException {
// Insert redirected hosts
insertRedirectedHost("github.com", "1.1.1.1", USER_SOURCE_ID);
insertRedirectedHost("github.com", "2.2.2.2", EXTERNAL_SOURCE_ID);
this.hostEntryDao.sync();
// Test inserted blocked hosts
assertEquals(1, getOrAwaitValue(this.redirectedHostCount).intValue());
assertEquals(1, this.hostEntryDao.getAll().size());
// Disabled a source
this.hostsSourceDao.toggleEnabled(this.externalHostSource);
this.hostEntryDao.sync();
assertEquals(1, getOrAwaitValue(this.redirectedHostCount).intValue());
assertEquals(1, this.hostEntryDao.getAll().size());
// Re-enable a source
this.hostsSourceDao.toggleEnabled(this.externalHostSource);
this.hostEntryDao.sync();
assertEquals(1, getOrAwaitValue(this.redirectedHostCount).intValue());
assertEquals(1, this.hostEntryDao.getAll().size());
}
}

View file

@ -0,0 +1,157 @@
package org.adaway.db;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
import static org.adaway.db.entity.ListType.ALLOWED;
import static org.adaway.db.entity.ListType.BLOCKED;
import static org.adaway.db.entity.ListType.REDIRECTED;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import org.adaway.db.entity.HostEntry;
import org.junit.Test;
/**
* This class the user lists use case where user can freely add blocked, allowed and redirected hosts.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class UserListTest extends DbTest {
@Test
public void testUserList() throws InterruptedException {
testUserBlockedHosts();
testUserAllowedHosts();
testUserRedirectedHosts();
}
protected void testUserBlockedHosts() throws InterruptedException {
// Insert blocked hosts
insertBlockedHost("advertising.apple.com", USER_SOURCE_ID);
insertBlockedHost("an.facebook.com", USER_SOURCE_ID);
insertBlockedHost("ads.google.com", USER_SOURCE_ID);
insertBlockedHost("bingads.microsoft.com", USER_SOURCE_ID);
insertBlockedHost("ads.yahoo.com", USER_SOURCE_ID);
this.hostEntryDao.sync();
// Test inserted blocked hosts
assertEquals(5, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(5, this.hostEntryDao.getAll().size());
// Test each host type
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("advertising.apple.com"));
HostEntry entry = this.hostEntryDao.getEntry("advertising.apple.com");
assertNotNull(entry);
assertEquals("advertising.apple.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("an.facebook.com"));
entry = this.hostEntryDao.getEntry("an.facebook.com");
assertNotNull(entry);
assertEquals("an.facebook.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("ads.google.com"));
entry = this.hostEntryDao.getEntry("ads.google.com");
assertNotNull(entry);
assertEquals("ads.google.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("bingads.microsoft.com"));
entry = this.hostEntryDao.getEntry("bingads.microsoft.com");
assertNotNull(entry);
assertEquals("bingads.microsoft.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("ads.yahoo.com"));
entry = this.hostEntryDao.getEntry("ads.yahoo.com");
assertNotNull(entry);
assertEquals("ads.yahoo.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
}
protected void testUserAllowedHosts() throws InterruptedException {
// Insert allowed hosts
insertAllowedHost("*.google.com", USER_SOURCE_ID);
insertAllowedHost("ads.yahoo.com", USER_SOURCE_ID);
insertAllowedHost("adaway.org", USER_SOURCE_ID);
this.hostEntryDao.sync();
// Test inserted allowed hosts
assertEquals(3, getOrAwaitValue(this.allowedHostCount).intValue());
// Test overall list
assertEquals(5, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(3, this.hostEntryDao.getAll().size());
// Test each host type
assertEquals(ALLOWED, this.hostEntryDao.getTypeForHost("adaway.org"));
HostEntry entry = this.hostEntryDao.getEntry("adaway.org");
assertNull(entry);
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("advertising.apple.com"));
entry = this.hostEntryDao.getEntry("advertising.apple.com");
assertNotNull(entry);
assertEquals("advertising.apple.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("an.facebook.com"));
entry = this.hostEntryDao.getEntry("an.facebook.com");
assertNotNull(entry);
assertEquals("an.facebook.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(ALLOWED, this.hostEntryDao.getTypeForHost("ads.google.com"));
entry = this.hostEntryDao.getEntry("ads.google.com");
assertNull(entry);
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("bingads.microsoft.com"));
entry = this.hostEntryDao.getEntry("bingads.microsoft.com");
assertNotNull(entry);
assertEquals("bingads.microsoft.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(ALLOWED, this.hostEntryDao.getTypeForHost("ads.yahoo.com"));
entry = this.hostEntryDao.getEntry("ads.yahoo.com");
assertNull(entry);
}
protected void testUserRedirectedHosts() throws InterruptedException {
// Insert redirected hosts
insertRedirectedHost("ads.yahoo.com", "1.2.3.4", USER_SOURCE_ID);
insertRedirectedHost("github.com", "1.2.3.4", USER_SOURCE_ID);
this.hostEntryDao.sync();
// Test inserted redirected hosts
assertEquals(2, getOrAwaitValue(this.redirectedHostCount).intValue());
// Test overall list
assertEquals(5, getOrAwaitValue(this.blockedHostCount).intValue());
assertEquals(3, getOrAwaitValue(this.allowedHostCount).intValue());
assertEquals(5, this.hostEntryDao.getAll().size()); // 3 blocked, 2 redirected
// Test each host type
assertEquals(ALLOWED, this.hostEntryDao.getTypeForHost("adaway.org"));
HostEntry entry = this.hostEntryDao.getEntry("adaway.org");
assertNull(entry);
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("advertising.apple.com"));
entry = this.hostEntryDao.getEntry("advertising.apple.com");
assertNotNull(entry);
assertEquals("advertising.apple.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("an.facebook.com"));
entry = this.hostEntryDao.getEntry("an.facebook.com");
assertNotNull(entry);
assertEquals("an.facebook.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(REDIRECTED, this.hostEntryDao.getTypeForHost("github.com"));
entry = this.hostEntryDao.getEntry("github.com");
assertNotNull(entry);
assertEquals("github.com", entry.getHost());
assertEquals(REDIRECTED, entry.getType());
assertEquals("1.2.3.4", entry.getRedirection());
assertEquals(ALLOWED, this.hostEntryDao.getTypeForHost("ads.google.com"));
entry = this.hostEntryDao.getEntry("ads.google.com");
assertNull(entry);
assertEquals(BLOCKED, this.hostEntryDao.getTypeForHost("bingads.microsoft.com"));
entry = this.hostEntryDao.getEntry("bingads.microsoft.com");
assertNotNull(entry);
assertEquals("bingads.microsoft.com", entry.getHost());
assertEquals(BLOCKED, entry.getType());
assertEquals(REDIRECTED, this.hostEntryDao.getTypeForHost("ads.yahoo.com"));
entry = this.hostEntryDao.getEntry("ads.yahoo.com");
assertNotNull(entry);
assertEquals("ads.yahoo.com", entry.getHost());
assertEquals(REDIRECTED, entry.getType());
assertEquals("1.2.3.4", entry.getRedirection());
}
}

View file

@ -0,0 +1,177 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<permission-group
android:name="org.adaway.permission-group.API"
android:description="@string/permission_group_api_description"
android:label="@string/permission_group_api_label" />
<permission
android:name="org.adaway.permission.SEND_COMMAND"
android:description="@string/permission_send_command_description"
android:label="@string/permission_send_command_label"
android:permissionGroup="org.adaway.permission-group.API"
android:protectionLevel="dangerous" />
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
android:normalScreens="true"
android:smallScreens="true"
android:xlargeScreens="true" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".AdAwayApplication"
android:allowBackup="true"
android:backupAgent=".model.backup.AppBackupAgent"
android:fullBackupOnly="false"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.AdAway"
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name=".ui.home.HomeActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.AdAway.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".ui.welcome.WelcomeActivity"
android:exported="false"
android:label="@string/welcome_title"
android:parentActivityName=".ui.home.HomeActivity"
android:theme="@style/Theme.AdAway.NoActionBar.Red" />
<activity
android:name=".ui.hosts.HostsSourcesActivity"
android:exported="false"
android:label="@string/hosts_title"
android:parentActivityName=".ui.home.HomeActivity" />
<activity
android:name=".ui.source.SourceEditActivity"
android:exported="false"
android:label="@string/source_edit_title"
android:parentActivityName=".ui.hosts.HostsSourcesActivity" />
<activity
android:name=".ui.log.LogActivity"
android:label="@string/shortcut_dns_requests"
android:parentActivityName=".ui.home.HomeActivity" />
<activity
android:name=".ui.lists.ListsActivity"
android:exported="false"
android:label="@string/lists_title"
android:launchMode="singleTop"
android:parentActivityName=".ui.home.HomeActivity">
<meta-data
android:name="android.app.searchable"
android:resource="@xml/list_searchable" />
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
</activity>
<activity
android:name=".ui.help.HelpActivity"
android:exported="false"
android:label="@string/menu_help"
android:parentActivityName=".ui.home.HomeActivity" />
<activity
android:name=".ui.support.SupportActivity"
android:exported="false"
android:label="@string/support_title"
android:parentActivityName=".ui.home.HomeActivity"
android:theme="@style/Theme.AdAway.NoActionBar.Red" />
<activity
android:name=".ui.prefs.PrefsActivity"
android:exported="false"
android:label="@string/preferences_drawer_item"
android:parentActivityName=".ui.home.HomeActivity" />
<activity
android:name=".ui.prefs.exclusion.PrefsVpnExcludedAppsActivity"
android:exported="false"
android:label="@string/pref_vpn_exclude_user_apps_activity" />
<activity
android:name=".ui.update.UpdateActivity"
android:exported="false"
android:label="@string/update_title"
android:parentActivityName=".ui.home.HomeActivity"
android:theme="@style/Theme.AdAway.NoActionBar.Red" />
<service
android:name=".vpn.VpnService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service
android:name=".tile.AdBlockingTileService"
android:exported="true"
android:icon="@drawable/logo"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<receiver
android:name=".broadcast.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".broadcast.CommandReceiver"
android:exported="true"
android:permission="org.adaway.permission.SEND_COMMAND">
<intent-filter>
<action android:name="org.adaway.action.SEND_COMMAND" />
</intent-filter>
</receiver>
<receiver
android:name=".broadcast.UpdateReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<meta-data
android:name="io.sentry.auto-init"
android:value="false" />
<meta-data
android:name="io.sentry.dsn"
android:value="https://8dac17b798fb45e492278a678c5ab028@o209266.ingest.us.sentry.io/1331667" />
<meta-data android:name="io.sentry.traces.user-interaction.enable" android:value="true" />
</application>
</manifest>

View file

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="100%"
height="100%"
viewBox="0 0 32 32"
id="svg3167"
version="1.1"
inkscape:version="0.48.5 r10040"
sodipodi:docname="icon-lite.svg">
<defs
id="defs3169">
<radialGradient
r="17.497915"
fy="40.636124"
fx="33.772423"
cy="40.636124"
cx="33.772423"
gradientTransform="matrix(1.161721,0,0,1.0498451,-2.6955965,978.41529)"
gradientUnits="userSpaceOnUse"
id="radialGradient3878"
xlink:href="#linearGradient4263"
inkscape:collect="always" />
<linearGradient
id="linearGradient4263">
<stop
id="stop4265"
offset="0"
style="stop-color:#bfbfbf;stop-opacity:1;" />
<stop
id="stop4267"
offset="1"
style="stop-color:#ffffff;stop-opacity:1;" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4263"
id="radialGradient3233"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.161721,0,0,1.0498451,-2.6955965,978.41529)"
cx="33.772423"
cy="40.636124"
fx="33.772423"
fy="40.636124"
r="17.497915" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4263"
id="radialGradient3238"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.61137216,0,0,0.55249588,-4.1660353,-4.330286)"
cx="33.772423"
cy="40.636124"
fx="33.772423"
fy="40.636124"
r="17.497915" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4263"
id="radialGradient3241"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.61137216,0,0,0.55249588,-3.9874288,-4.330286)"
cx="33.772423"
cy="40.636124"
fx="33.772423"
fy="40.636124"
r="17.497915" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="15.836083"
inkscape:cx="5.6448078"
inkscape:cy="16.057137"
inkscape:current-layer="svg3167"
showgrid="true"
inkscape:grid-bbox="true"
inkscape:document-units="px"
inkscape:window-width="2560"
inkscape:window-height="1387"
inkscape:window-x="1672"
inkscape:window-y="-8"
inkscape:window-maximized="1" />
<metadata
id="metadata3172">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<path
style="fill:#cc0000;fill-opacity:0.11640212;stroke:#333333;stroke-width:0.46215421000000001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0.23529412000000000;stroke-dasharray:none;stroke-dashoffset:0"
d="M 9.90625 1.21875 L 1.25 9.875 L 1.21875 22.09375 L 9.875 30.75 L 22.09375 30.78125 L 30.75 22.125 L 30.78125 9.90625 L 22.125 1.25 L 9.90625 1.21875 z M 6 8.1875 C 6.6763721 8.1197446 10.285042 9.6406437 14.5625 9.65625 C 16.759542 9.791605 17.570434 10.416353 18.1875 11.0625 C 19.571912 12.366793 19.873633 13.03126 21.625 12.1875 C 22.338438 11.843849 23.228376 9.8114647 24.21875 9.59375 C 24.990847 9.481077 26.122457 10.438725 26.40625 10.71875 C 26.716053 11.02451 26.985342 11.333132 27.28125 11.46875 C 26.265273 11.52259 24.879528 13.892184 24.84375 15.90625 C 25.046345 21.533487 22.49174 21.215441 20 23.96875 C 19.205534 24.725992 18.781983 26.303089 18.78125 27.75 L 17.71875 27.71875 C 17.098386 27.73296 16.405511 26.947039 16.0625 26.40625 C 15.407551 25.314883 14.737631 27.401017 13.71875 25.6875 C 13.7659 25.120872 10.972706 25.011002 12.5625 23.6875 C 16.317406 21.029656 18.172296 20.45754 18.09375 19.65625 C 18.00095 18.709606 15.800894 19.560638 13.8125 19.46875 C 10.527778 19.317028 10.884928 17.249787 10.59375 16.28125 C 7.8361706 14.745664 12.704357 14.015508 10.5625 13.5625 C 9.5159322 13.396095 7.6703939 13.778783 7.28125 11.96875 C 7.6312051 12.401076 12.220507 11.538235 10.375 11.25 C 7.814251 10.088009 7.0930254 10.68347 6.03125 8.5 C 5.8473548 8.2768081 5.8439141 8.2031359 6 8.1875 z "
id="path3852" />
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDTTCCAjWgAwIBAgIUR9ZJhU2vy/hB7LChIPgUuBEzqkQwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MTAyNzA2NTEwNVoXDTI3MTEy
NzA2NTEwNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAoVNvAzvXCjH07lgGTwBRMOFNuvsIBzi5rtJw8EQI1+np
h8jZjGmTq759VoqsRSrzPSb8W4XNmGlg8gm0nRN9VJXE9IccWQmpbl61MG6zu9Ae
/RZ8iAuydztn16/sOVQMT3Y0dXl3Fz9VtSEAMWZG9iATlVuugShLbod+Smw3mQow
2Z6Mfg6u4vpPCLG13Hdilv+UGs19dUKlFJdsz2M/gsk0vIlyhaFc9PcyzKJG4Tk2
XZOHEWMj7VjmEMZoUogihi9EWPLpBoi86dIWBBQfhu9HTwzl1BE33TMtYLPdKdxD
9I9QPg59wbquiVYZ3sPVISdks4qmg4NgzBJVB/tWhQIDAQABo4GWMIGTMEIGA1Ud
EQQ7MDmCCWxvY2FsaG9zdIIRKi5kb3VibGVjbGljay5uZXSCEyouZy5kb3VibGVj
bGljay5uZXSHBH8AAAEwCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMB
MAwGA1UdEwQFMAMBAf8wHQYDVR0OBBYEFPyh/TOOyMqOumAhxn1Jsyb09CQ3MA0G
CSqGSIb3DQEBCwUAA4IBAQAdxRNksyLjIwVaKjmC+Yx87zKw8bSmH2mm85KJ0Y7z
uoWLdiw5Lc7/uZoXEl4KGJEnAXb83lo2042WCn49bOhAtYv7tOBTvTGhCHCZ6p7u
Lk+dN8eOkfKgIpKv+tyt0y+Dl+K9m0TegBqky03xf+gjfMaNctwkxxl7Ls4CR9Lz
Bufvt1HxJkmSILmTy8ByJPG5ePN6By4YiEwg9h2Hdx7zEqAKkgHvr4Yn/c4GzDQ+
UZZW9NCIsKkaV32UfpCSodlpc+xeayX8jxZW2GnXs31IjJd4KuNfK5g7ZuOIZN0S
iKvTwJkScrfvLlmbCqEJ7Nu4l/XR+5EAy21YNlV+mo7D
-----END CERTIFICATE-----

View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQChU28DO9cKMfTu
WAZPAFEw4U26+wgHOLmu0nDwRAjX6emHyNmMaZOrvn1WiqxFKvM9Jvxbhc2YaWDy
CbSdE31UlcT0hxxZCaluXrUwbrO70B79FnyIC7J3O2fXr+w5VAxPdjR1eXcXP1W1
IQAxZkb2IBOVW66BKEtuh35KbDeZCjDZnox+Dq7i+k8IsbXcd2KW/5QazX11QqUU
l2zPYz+CyTS8iXKFoVz09zLMokbhOTZdk4cRYyPtWOYQxmhSiCKGL0RY8ukGiLzp
0hYEFB+G70dPDOXUETfdMy1gs90p3EP0j1A+Dn3Buq6JVhnew9UhJ2SziqaDg2DM
ElUH+1aFAgMBAAECggEAErRYPjE9dP6mzc2h6Z35S+gLeZ7qZt/yU20t0AWrWtFR
lL86Tffdub9ry9FnONvKePAguUHRvRaWuWlbqgyc7uYwgEN8C2y92sCbVGK5bxCp
zyFAzgtBJWbbWtwYUOtIRBxJ58buAmGC/+20FoYruxSsAJixKmNwH4ARKfLTHWis
bwzULTA4fhmvPgv+DWg7rxOTZP+7Jl5xFQnj4lXQaTt44B9DQF7ArtVjwHJWGbAN
muc4Ij/2lzD1EFNv9hXRvPjnhdN8WuDXZ52BTNj3ZXEYYyjw6Mvy7Qtsiz4FovpT
UvIsQgUyvkyukKX+8rQb+JAcfZQHnfXFSoZyIJ/KDwKBgQDQAY7L6o/ZwQ2FjyyN
K0Zu4oozQh1VM6C8eGtOsa6b+1y1d3RQg0JYb4ay1KwyFAUVnDSx/dNVg8LxVbL+
st0EAUYmyNtozAerkAAdbYLr/mDKM4pYp9OzamqJYaUVXDa70zEOe/Q86a4KKa8l
mr6jfd2+OHhBjFfc/UWfZKWAiwKBgQDGjJXM8KvFIqkBnNqcy1NhhtUUAwzV9oye
HsagZBiTDXeCq2FUOJRh1474az+X12PTCDHNobvhGwz+eYe8yfDmILE9RmjbtCwO
YzTFuEyzFxG4aWAVV9/u+qWu8LQFeXRVOyztNFmIXqWaaCg0tahaVKI0dukMk+/1
M4b/fGzXLwKBgCBxfcJUjadbMy63zC0gqNW2w/OGxmh5qwJ6jdIyaJevtyAex6ef
MYP1sT7HaSxObxSVzqpMeuAFsyxNP6P2Zf6v7C80ePR5jmC2Dy6H3DnO7W3caCG3
249Kc9+FuWgBgA//utEViFzP3fN72PO2lTGO+j0nNaqTp0iywF9CJYZNAoGAXo/k
ZKgXVxuL3K3E3Lpl6uQZpZ9SRLFZBZHozcj+f0MBsWVIRKFx4iuU9zG1Ju85pu+X
MLWf0rVcefKNuFeBeUkGwQVAuarU9MFBCA4f0YfiM69USLYCfEI6GNihFJ5kzpcR
baPqJG3Xd3O1+myuUt9OJaiglBH9Tg4NdK7g85cCgYAYL3r9a8LosXGwtqiahX9o
Hyu/lRrKDH6yvUN2YeHN10pyY8rkdLAZ6E6imgxHqA8n8AibhkoaGpTrBlKRgP1/
MMjeqy3C3hYjBQdfs+Vv6YtsufhegHs84/tgDxyfkWcljeXg2GoZvPQxANNZe8QE
JTaivHj2kiF8ZakITrXT+A==
-----END PRIVATE KEY-----

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web server test page</title>
</head>
<body>
<h1>Web server works fine</h1>
<img width="60%" src="icon.png" alt="AdAway logo when option is enabled" style="display: block; margin: auto;">
<p>If you can see this page, that means the web server is working fine and its certificate is installed.</p>
</body>
</html>

BIN
app/src/main/icon-web.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,76 @@
package org.adaway;
import android.app.Application;
import org.adaway.helper.NotificationHelper;
import org.adaway.helper.PreferenceHelper;
import org.adaway.model.adblocking.AdBlockMethod;
import org.adaway.model.adblocking.AdBlockModel;
import org.adaway.model.source.SourceModel;
import org.adaway.model.update.UpdateModel;
import org.adaway.util.log.ApplicationLog;
/**
* This class is a custom {@link Application} for AdAway app.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class AdAwayApplication extends Application {
/**
* The common source model for the whole application.
*/
private SourceModel sourceModel;
/**
* The common ad block model for the whole application.
*/
private AdBlockModel adBlockModel;
/**
* The common update model for the whole application.
*/
private UpdateModel updateModel;
@Override
public void onCreate() {
// Delegate application creation
super.onCreate();
// Initialize logging
ApplicationLog.init(this);
// Create notification channels
NotificationHelper.createNotificationChannels(this);
// Create models
this.sourceModel = new SourceModel(this);
this.updateModel = new UpdateModel(this);
}
/**
* Get the source model.
*
* @return The common source model for the whole application.
*/
public SourceModel getSourceModel() {
return this.sourceModel;
}
/**
* Get the ad block model.
*
* @return The common ad block model for the whole application.
*/
public AdBlockModel getAdBlockModel() {
// Check cached model
AdBlockMethod method = PreferenceHelper.getAdBlockMethod(this);
if (this.adBlockModel == null || this.adBlockModel.getMethod() != method) {
this.adBlockModel = AdBlockModel.build(this, method);
}
return this.adBlockModel;
}
/**
* Get the update model.
*
* @return Teh common update model for the whole application.
*/
public UpdateModel getUpdateModel() {
return this.updateModel;
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.broadcast;
import static android.content.Intent.ACTION_BOOT_COMPLETED;
import static org.adaway.model.adblocking.AdBlockMethod.ROOT;
import static org.adaway.model.adblocking.AdBlockMethod.VPN;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.adaway.helper.PreferenceHelper;
import org.adaway.model.adblocking.AdBlockMethod;
import org.adaway.util.WebServerUtils;
import org.adaway.vpn.VpnServiceControls;
import timber.log.Timber;
/**
* This broadcast receiver is executed after boot.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
Timber.d("BootReceiver invoked.");
AdBlockMethod adBlockMethod = PreferenceHelper.getAdBlockMethod(context);
// Start web server on boot if enabled in preferences
if (adBlockMethod == ROOT && PreferenceHelper.getWebServerEnabled(context)) {
WebServerUtils.startWebServer(context);
}
if (adBlockMethod == VPN && PreferenceHelper.getVpnServiceOnBoot(context)) {
// Ensure VPN is prepared
Intent prepareIntent = android.net.VpnService.prepare(context);
if (prepareIntent != null) {
context.startActivity(prepareIntent);
}
// Start VPN service if enabled in preferences
VpnServiceControls.start(context);
}
}
}
}

View file

@ -0,0 +1,57 @@
package org.adaway.broadcast;
import android.content.Intent;
import timber.log.Timber;
/**
* This enumerate lists the commands of {@link CommandReceiver}.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public enum Command {
/**
* Start the ad-blocking.
*/
START,
/**
* Stop the ad-blocking.
*/
STOP,
/**
* Unknown command.
*/
UNKNOWN;
private static final String INTENT_EXTRA_COMMAND = "COMMAND";
/**
* Read command from intent.
*
* @param intent The intent to read command from.
* @return The read intent.
*/
public static Command readFromIntent(Intent intent) {
Command command = UNKNOWN;
if (intent != null && intent.hasExtra(INTENT_EXTRA_COMMAND)) {
String commandName = intent.getStringExtra(INTENT_EXTRA_COMMAND);
if (commandName != null) {
try {
command = Command.valueOf(commandName);
} catch (IllegalArgumentException e) {
Timber.w("Failed to read command named %s.", commandName);
}
}
}
return command;
}
/**
* Append command to intent.
*
* @param intent The intent to append command to.
*/
public void appendToIntent(Intent intent) {
intent.putExtra(INTENT_EXTRA_COMMAND, name());
}
}

View file

@ -0,0 +1,53 @@
package org.adaway.broadcast;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.adaway.AdAwayApplication;
import org.adaway.model.adblocking.AdBlockModel;
import org.adaway.model.error.HostErrorException;
import org.adaway.util.AppExecutors;
import timber.log.Timber;
/**
* This broadcast receiver listens to commands from broadcast.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class CommandReceiver extends BroadcastReceiver {
/**
* This action allows to send commands to the application. See {@link Command} for extra values.
*/
public static final String SEND_COMMAND_ACTION = "org.adaway.action.SEND_COMMAND";
private static final AppExecutors EXECUTORS = AppExecutors.getInstance();
@Override
public void onReceive(Context context, Intent intent) {
if (SEND_COMMAND_ACTION.equals(intent.getAction())) {
AdBlockModel adBlockModel = ((AdAwayApplication) context.getApplicationContext()).getAdBlockModel();
Command command = Command.readFromIntent(intent);
Timber.i("CommandReceiver invoked with command %s.", command);
EXECUTORS.diskIO().execute(() -> executeCommand(adBlockModel, command));
}
}
private void executeCommand(AdBlockModel adBlockModel, Command command) {
try {
switch (command) {
case START:
adBlockModel.apply();
break;
case STOP:
adBlockModel.revert();
break;
case UNKNOWN:
Timber.i("Failed to run an unsupported command.");
break;
}
} catch (HostErrorException e) {
Timber.w(e, "Failed to apply ad block command " + command + ".");
}
}
}

View file

@ -0,0 +1,28 @@
package org.adaway.broadcast;
import static android.content.Intent.ACTION_MY_PACKAGE_REPLACED;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.adaway.AdAwayApplication;
import timber.log.Timber;
/**
* This broadcast receiver is executed at application update.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class UpdateReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())) {
AdAwayApplication application = (AdAwayApplication) context.getApplicationContext();
String versionName = application.getUpdateModel().getVersionName();
Timber.d("UpdateReceiver invoked");
Timber.i("Application update to version %s", versionName);
}
}
}

View file

@ -0,0 +1,134 @@
package org.adaway.db;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
import androidx.sqlite.db.SupportSQLiteDatabase;
import org.adaway.R;
import org.adaway.db.converter.ListTypeConverter;
import org.adaway.db.converter.ZonedDateTimeConverter;
import org.adaway.db.dao.HostEntryDao;
import org.adaway.db.dao.HostListItemDao;
import org.adaway.db.dao.HostsSourceDao;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.HostsSource;
import org.adaway.db.entity.HostEntry;
import org.adaway.util.AppExecutors;
import static org.adaway.db.Migrations.MIGRATION_1_2;
import static org.adaway.db.Migrations.MIGRATION_2_3;
import static org.adaway.db.Migrations.MIGRATION_3_4;
import static org.adaway.db.Migrations.MIGRATION_4_5;
import static org.adaway.db.Migrations.MIGRATION_5_6;
import static org.adaway.db.Migrations.MIGRATION_6_7;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_URL;
/**
* This class is the application database based on Room.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
@Database(entities = {HostsSource.class, HostListItem.class, HostEntry.class}, version = 7)
@TypeConverters({ListTypeConverter.class, ZonedDateTimeConverter.class})
public abstract class AppDatabase extends RoomDatabase {
/**
* The database singleton instance.
*/
private static volatile AppDatabase instance;
/**
* Get the database instance.
*
* @param context The application context.
* @return The database instance.
*/
public static AppDatabase getInstance(Context context) {
if (instance == null) {
synchronized (AppDatabase.class) {
if (instance == null) {
instance = Room.databaseBuilder(
context.getApplicationContext(),
AppDatabase.class,
"app.db"
).addCallback(new Callback() {
@Override
public void onCreate(@NonNull SupportSQLiteDatabase db) {
AppExecutors.getInstance().diskIO().execute(
() -> AppDatabase.initialize(context, instance)
);
}
}).addMigrations(
MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7
).build();
}
}
}
return instance;
}
/**
* Initialize the database content.
*/
private static void initialize(Context context, AppDatabase database) {
// Check if there is no hosts source
HostsSourceDao hostsSourceDao = database.hostsSourceDao();
if (!hostsSourceDao.getAll().isEmpty()) {
return;
}
// User list
HostsSource userSource = new HostsSource();
userSource.setLabel(context.getString(R.string.hosts_user_source));
userSource.setId(USER_SOURCE_ID);
userSource.setUrl(USER_SOURCE_URL);
userSource.setAllowEnabled(true);
userSource.setRedirectEnabled(true);
hostsSourceDao.insert(userSource);
// AdAway official
HostsSource source1 = new HostsSource();
source1.setLabel(context.getString(R.string.hosts_adaway_source));
source1.setUrl("https://adaway.org/hosts.txt");
hostsSourceDao.insert(source1);
// StevenBlack
HostsSource source2 = new HostsSource();
source2.setLabel(context.getString(R.string.hosts_stevenblack_source));
source2.setUrl("https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts");
hostsSourceDao.insert(source2);
// Pete Lowe
HostsSource source3 = new HostsSource();
source3.setLabel(context.getString(R.string.hosts_peterlowe_source));
source3.setUrl("https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&showintro=0&mimetype=plaintext");
hostsSourceDao.insert(source3);
}
/**
* Get the hosts source DAO.
*
* @return The hosts source DAO.
*/
public abstract HostsSourceDao hostsSourceDao();
/**
* Get the hosts list item DAO.
*
* @return The hosts list item DAO.
*/
public abstract HostListItemDao hostsListItemDao();
/**
* Get the hosts entry DAO.
*
* @return The hosts entry DAO.
*/
public abstract HostEntryDao hostEntryDao();
}

View file

@ -0,0 +1,123 @@
package org.adaway.db;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_URL;
import androidx.annotation.NonNull;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
/**
* This class declares database schema migrations.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
final class Migrations {
/**
* Private constructor of utility class.
*/
private Migrations() {
}
/**
* The migration script from v1 to v2.
*/
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// Add hosts sources id column and migrate data
database.execSQL("CREATE TABLE `hosts_sources_new` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_modified_local` INTEGER, `last_modified_online` INTEGER)");
database.execSQL("INSERT INTO `hosts_sources_new` (`id`, `url`, `enabled`) VALUES (" + USER_SOURCE_ID + ", '" + USER_SOURCE_URL + "', 1)");
database.execSQL("INSERT INTO `hosts_sources_new` (`url`, `enabled`, `last_modified_local`, `last_modified_online`) SELECT `url`, `enabled`, `last_modified_local`, `last_modified_online` FROM `hosts_sources`");
database.execSQL("DROP TABLE `hosts_sources`");
database.execSQL("ALTER TABLE `hosts_sources_new` RENAME TO `hosts_sources`");
// Add hosts list source id and migrate data
database.execSQL("CREATE TABLE `hosts_lists_new` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `host` TEXT NOT NULL, `type` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `redirection` TEXT, `source_id` INTEGER NOT NULL, FOREIGN KEY(`source_id`) REFERENCES `hosts_sources`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )");
database.execSQL("INSERT INTO `hosts_lists_new` (`host`, `type`, `enabled`, `redirection`, `source_id`) SELECT `host`, `type`, `enabled`, `redirection`, " + USER_SOURCE_ID + " FROM `hosts_lists`");
database.execSQL("DROP TABLE `hosts_lists`");
database.execSQL("ALTER TABLE `hosts_lists_new` RENAME TO `hosts_lists`");
// Create index
database.execSQL("CREATE UNIQUE INDEX `index_hosts_sources_url` ON `hosts_sources` (`url`)");
database.execSQL("CREATE UNIQUE INDEX `index_hosts_lists_host` ON `hosts_lists` (`host`)");
database.execSQL("CREATE INDEX `index_hosts_lists_source_id` ON `hosts_lists` (`source_id`)");
}
};
/**
* The migration script from v2 to v3.
*/
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("CREATE VIEW `host_entries` AS SELECT `host`, `type`, `redirection` FROM `hosts_lists` WHERE `enabled` = 1 AND ((`type` = 0 AND `host` NOT LIKE (SELECT `host` FROM `hosts_lists` WHERE `enabled` = 1 and `type` = 1)) OR `type` = 2) ORDER BY `host` ASC, `type` DESC, `redirection` ASC");
}
};
/**
* Migration script from v3 to v4.
*/
static final Migration MIGRATION_3_4 = new Migration(3, 4) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// Remove unique constraint to hosts_lists.host column
database.execSQL("DROP INDEX `index_hosts_lists_host`");
database.execSQL("CREATE INDEX `index_hosts_lists_host` ON `hosts_lists` (`host`)");
// Update host_entries view
database.execSQL("DROP VIEW `host_entries`");
database.execSQL("CREATE VIEW `host_entries` AS SELECT `host`, `type`, `redirection` FROM `hosts_lists` WHERE `enabled` = 1 AND ((`type` = 0 AND `host` NOT LIKE (SELECT `host` FROM `hosts_lists` WHERE `enabled` = 1 and `type` = 1)) OR `type` = 2) GROUP BY `host` ORDER BY `host` ASC, `type` DESC, `redirection` ASC");
}
};
/**
* Migration script from v4 to v5.
*/
static final Migration MIGRATION_4_5 = new Migration(4, 5) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// Remove host_entries view
database.execSQL("DROP VIEW `host_entries`");
// Create new host_entries table
database.execSQL("CREATE TABLE IF NOT EXISTS `host_entries` (`host` TEXT NOT NULL, `type` INTEGER NOT NULL, `redirection` TEXT, PRIMARY KEY(`host`))");
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_host_entries_host` ON `host_entries` (`host`)");
}
};
/**
* Migration script from v5 to v6.
*/
static final Migration MIGRATION_5_6 = new Migration(5, 6) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// Update hosts_sources table
database.execSQL("ALTER TABLE `hosts_sources` ADD `label` TEXT NOT NULL DEFAULT \"\"");
database.execSQL("ALTER TABLE `hosts_sources` ADD `allowEnabled` INTEGER NOT NULL DEFAULT 0");
database.execSQL("ALTER TABLE `hosts_sources` ADD `redirectEnabled` INTEGER NOT NULL DEFAULT 0");
database.execSQL("ALTER TABLE `hosts_sources` ADD `size` INTEGER NOT NULL DEFAULT 0");
// Set default values to new source attributes
database.execSQL("UPDATE `hosts_sources` SET `label` = `url`");
// Update user hosts list
database.execSQL("UPDATE `hosts_sources` SET `url` = \"content://org.adaway/user/hosts\", `allowEnabled` = 1, `redirectEnabled` = 1 WHERE `url` = \"file://app/user/hosts\"");
// Update default hosts source label
database.execSQL("UPDATE `hosts_sources` SET `label` = \"AdAway official hosts\" WHERE `url` = \"https://adaway.org/hosts.txt\"");
database.execSQL("UPDATE `hosts_sources` SET `label` = \"StevenBlack Unified hosts\" WHERE `url` = \"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts\"");
database.execSQL("UPDATE `hosts_sources` SET `label` = \"Pete Lowe blocklist hosts\" WHERE `url` = \"https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&showintro=0&mimetype=plaintext\"");
// Reset local date to rebuild cache
database.execSQL("UPDATE `hosts_sources` SET `last_modified_local` = NULL");
// Update hosts source date format
database.execSQL("UPDATE `hosts_sources` SET `last_modified_online` = `last_modified_online` / 1000");
// Clear previous file type hosts sources
database.execSQL("DELETE FROM `hosts_sources` WHERE `url` LIKE \"file://%\"");
}
};
/**
* Migration script from v6 to v7.
*/
static final Migration MIGRATION_6_7 = new Migration(6, 7) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// Update hosts_sources table
database.execSQL("ALTER TABLE `hosts_sources` ADD `entityTag` TEXT DEFAULT NULL");
}
};
}

View file

@ -0,0 +1,26 @@
package org.adaway.db.converter;
import androidx.room.TypeConverter;
import org.adaway.db.entity.ListType;
/**
* This class is a type converter for Room to support {@link ListType} type.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class ListTypeConverter {
private ListTypeConverter() {
// Prevent instantiation
}
@TypeConverter
public static ListType fromValue(Integer value) {
return value == null ? null : ListType.fromValue(value);
}
@TypeConverter
public static Integer typeToValue(ListType type) {
return type == null ? null : type.getValue();
}
}

View file

@ -0,0 +1,30 @@
package org.adaway.db.converter;
import androidx.room.TypeConverter;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import static java.time.ZoneOffset.UTC;
/**
* This class is a type converter for Room to support {@link java.time.ZonedDateTime} type.
* It is stored as a Unix epoc timestamp.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class ZonedDateTimeConverter {
private ZonedDateTimeConverter() {
// Prevent instantiation
}
@TypeConverter
public static ZonedDateTime fromTimestamp(Long value) {
return value == null ? null : ZonedDateTime.of(LocalDateTime.ofEpochSecond(value, 0, UTC), UTC);
}
@TypeConverter
public static Long toTimestamp(ZonedDateTime zonedDateTime) {
return zonedDateTime == null ? null : zonedDateTime.toEpochSecond();
}
}

View file

@ -0,0 +1,78 @@
package org.adaway.db.dao;
import androidx.annotation.Nullable;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.Query;
import org.adaway.db.entity.HostEntry;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.ListType;
import java.util.List;
import java.util.regex.Pattern;
import static androidx.room.OnConflictStrategy.REPLACE;
import static org.adaway.db.entity.ListType.REDIRECTED;
/**
* This interface is the DAO for {@link HostEntry} records.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
@Dao
public interface HostEntryDao {
Pattern ANY_CHAR_PATTERN = Pattern.compile("\\*");
Pattern A_CHAR_PATTERN = Pattern.compile("\\?");
@Query("DELETE FROM `host_entries`")
void clear();
@Query("INSERT INTO `host_entries` SELECT DISTINCT `host`, `type`, `redirection` FROM `hosts_lists` WHERE `type` = 0 AND `enabled` = 1")
void importBlocked();
@Query("SELECT host FROM hosts_lists WHERE type = 1 AND enabled = 1")
List<String> getEnabledAllowedHosts();
@Query("DELETE FROM `host_entries` WHERE `host` LIKE :hostPattern")
void allowHost(String hostPattern);
@Query("SELECT * FROM hosts_lists WHERE type = 2 AND enabled = 1 ORDER BY host ASC, source_id DESC")
List<HostListItem> getEnabledRedirectedHosts();
@Insert(onConflict = REPLACE)
void redirectHost(HostEntry redirection);
/**
* Synchronize the host entries based on the current hosts lists table records.
*/
default void sync() {
clear();
importBlocked();
for (String allowedHost : getEnabledAllowedHosts()) {
allowedHost = ANY_CHAR_PATTERN.matcher(allowedHost).replaceAll("%");
allowedHost = A_CHAR_PATTERN.matcher(allowedHost).replaceAll("_");
allowHost(allowedHost);
}
for (HostListItem redirectedHost : getEnabledRedirectedHosts()) {
HostEntry entry = new HostEntry();
entry.setHost(redirectedHost.getHost());
entry.setType(REDIRECTED);
entry.setRedirection(redirectedHost.getRedirection());
redirectHost(entry);
}
}
@Query("SELECT * FROM `host_entries` ORDER BY `host`")
List<HostEntry> getAll();
@Query("SELECT `type` FROM `host_entries` WHERE `host` == :host LIMIT 1")
ListType getTypeOfHost(String host);
@Query("SELECT IFNULL((SELECT `type` FROM `host_entries` WHERE `host` == :host LIMIT 1), 1)")
ListType getTypeForHost(String host);
@Nullable
@Query("SELECT * FROM `host_entries` WHERE `host` == :host LIMIT 1")
HostEntry getEntry(String host);
}

View file

@ -0,0 +1,63 @@
package org.adaway.db.dao;
import androidx.lifecycle.LiveData;
import androidx.paging.PagingSource;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Update;
import org.adaway.db.entity.HostListItem;
import java.util.List;
import java.util.Optional;
import static androidx.room.OnConflictStrategy.REPLACE;
/**
* This interface is the DAO for {@link HostListItem} entities.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
@Dao
public interface HostListItemDao {
@Insert(onConflict = REPLACE)
void insert(HostListItem... item);
@Insert(onConflict = REPLACE)
void insert(List<HostListItem> items);
@Update
void update(HostListItem item);
@Delete
void delete(HostListItem item);
@Query("DELETE FROM hosts_lists WHERE source_id = 1 AND host = :host")
void deleteUserFromHost(String host);
@Query("SELECT * FROM hosts_lists WHERE type = :type AND host LIKE :query AND ((:includeSources == 0 AND source_id == 1) || (:includeSources == 1)) GROUP BY host ORDER BY host ASC")
PagingSource<Integer, HostListItem> loadList(int type, boolean includeSources, String query);
@Query("SELECT * FROM hosts_lists ORDER BY host ASC")
List<HostListItem> getAll();
@Query("SELECT * FROM hosts_lists WHERE source_id = 1")
List<HostListItem> getUserList();
@Query("SELECT id FROM hosts_lists WHERE host = :host AND source_id = 1 LIMIT 1")
Optional<Integer> getHostId(String host);
@Query("SELECT COUNT(DISTINCT host) FROM hosts_lists WHERE type = 0 AND enabled = 1")
LiveData<Integer> getBlockedHostCount();
@Query("SELECT COUNT(DISTINCT host) FROM hosts_lists WHERE type = 1 AND enabled = 1")
LiveData<Integer> getAllowedHostCount();
@Query("SELECT COUNT(DISTINCT host) FROM hosts_lists WHERE type = 2 AND enabled = 1")
LiveData<Integer> getRedirectHostCount();
@Query("DELETE FROM hosts_lists WHERE source_id = :sourceId")
void clearSourceHosts(int sourceId);
}

View file

@ -0,0 +1,80 @@
package org.adaway.db.dao;
import androidx.lifecycle.LiveData;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Update;
import org.adaway.db.entity.HostsSource;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
import static androidx.room.OnConflictStrategy.IGNORE;
/**
* This interface is the DAO for {@link HostsSource} entities.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
@Dao
public interface HostsSourceDao {
@Insert(onConflict = IGNORE)
void insert(HostsSource source);
@Update
void update(HostsSource source);
@Delete
void delete(HostsSource source);
@Query("SELECT * FROM hosts_sources WHERE enabled = 1 AND id != 1 ORDER BY url ASC")
List<HostsSource> getEnabled();
default void toggleEnabled(HostsSource source) {
int id = source.getId();
boolean enabled = !source.isEnabled();
source.setEnabled(enabled);
setSourceEnabled(id, enabled);
setSourceItemsEnabled(id, enabled);
}
@Query("UPDATE hosts_sources SET enabled = :enabled WHERE id =:id")
void setSourceEnabled(int id, boolean enabled);
@Query("UPDATE hosts_lists SET enabled = :enabled WHERE source_id =:id")
void setSourceItemsEnabled(int id, boolean enabled);
@Query("SELECT * FROM hosts_sources WHERE id = :id")
Optional<HostsSource> getById(int id);
@Query("SELECT * FROM hosts_sources WHERE id != 1 ORDER BY label ASC")
List<HostsSource> getAll();
@Query("SELECT * FROM hosts_sources WHERE id != 1 ORDER BY label ASC")
LiveData<List<HostsSource>> loadAll();
@Query("UPDATE hosts_sources SET last_modified_online = :dateTime WHERE id = :id")
void updateOnlineModificationDate(int id, ZonedDateTime dateTime);
@Query("UPDATE hosts_sources SET last_modified_local = :localModificationDate, last_modified_online = :onlineModificationDate WHERE id = :id")
void updateModificationDates(int id, ZonedDateTime localModificationDate, ZonedDateTime onlineModificationDate);
@Query("UPDATE hosts_sources SET entityTag = :entityTag WHERE id = :id")
void updateEntityTag(int id, String entityTag);
@Query("UPDATE hosts_sources SET size = (SELECT count(id) FROM hosts_lists WHERE source_id = :id) WHERE id = :id")
void updateSize(int id);
@Query("SELECT count(id) FROM hosts_sources WHERE enabled = 1 AND last_modified_online > last_modified_local")
LiveData<Integer> countOutdated();
@Query("SELECT count(id) FROM hosts_sources WHERE enabled = 1 AND last_modified_online <= last_modified_local")
LiveData<Integer> countUpToDate();
@Query("UPDATE hosts_sources SET last_modified_local = NULL, last_modified_online = NULL, entityTag = NULL, size = 0 WHERE id = :id")
void clearProperties(int id);
}

View file

@ -0,0 +1,50 @@
package org.adaway.db.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.PrimaryKey;
/**
* This entity represents an entry of the built hosts file.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
@Entity(
tableName = "host_entries",
indices = {@Index(value = "host", unique = true)}
)
public class HostEntry {
@PrimaryKey
@NonNull
private String host;
@NonNull
private ListType type;
private String redirection;
@NonNull
public String getHost() {
return host;
}
public void setHost(@NonNull String host) {
this.host = host;
}
@NonNull
public ListType getType() {
return type;
}
public void setType(@NonNull ListType type) {
this.type = type;
}
public String getRedirection() {
return redirection;
}
public void setRedirection(String redirection) {
this.redirection = redirection;
}
}

View file

@ -0,0 +1,121 @@
package org.adaway.db.entity;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import java.util.Objects;
import static androidx.room.ForeignKey.CASCADE;
/**
* This entity represents a black, white or redirect list item.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
@Entity(
tableName = "hosts_lists",
indices = {
@Index(value = "host"),
@Index(value = "source_id")
},
foreignKeys = @ForeignKey(
entity = HostsSource.class,
parentColumns = "id",
childColumns = "source_id",
onUpdate = CASCADE,
onDelete = CASCADE
)
)
public class HostListItem {
@PrimaryKey(autoGenerate = true)
private int id;
@NonNull
private String host;
@NonNull
private ListType type;
private boolean enabled;
private String redirection;
@ColumnInfo(name = "source_id")
private int sourceId;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
@NonNull
public String getHost() {
return host;
}
public void setHost(@NonNull String host) {
this.host = host;
}
@NonNull
public ListType getType() {
return type;
}
public void setType(@NonNull ListType type) {
this.type = type;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getRedirection() {
return redirection;
}
public void setRedirection(String redirection) {
this.redirection = redirection;
}
public int getSourceId() {
return sourceId;
}
public void setSourceId(int sourceId) {
this.sourceId = sourceId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
HostListItem item = (HostListItem) o;
if (id != item.id) return false;
if (enabled != item.enabled) return false;
if (sourceId != item.sourceId) return false;
if (!host.equals(item.host)) return false;
if (type != item.type) return false;
return Objects.equals(redirection, item.redirection);
}
@Override
public int hashCode() {
int result = id;
result = 31 * result + host.hashCode();
result = 31 * result + type.hashCode();
result = 31 * result + (enabled ? 1 : 0);
result = 31 * result + (redirection != null ? redirection.hashCode() : 0);
result = 31 * result + sourceId;
return result;
}
}

View file

@ -0,0 +1,187 @@
package org.adaway.db.entity;
import android.webkit.URLUtil;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import java.time.ZonedDateTime;
import java.util.Objects;
import static org.adaway.db.entity.SourceType.FILE;
import static org.adaway.db.entity.SourceType.UNSUPPORTED;
import static org.adaway.db.entity.SourceType.URL;
/**
* This entity represents a source to get hosts list.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
@Entity(
tableName = "hosts_sources",
indices = {@Index(value = "url", unique = true)}
)
public class HostsSource {
/**
* The user source ID.
*/
public static final int USER_SOURCE_ID = 1;
/**
* The user source URL.
*/
public static final String USER_SOURCE_URL = "content://org.adaway/user/hosts";
@PrimaryKey(autoGenerate = true)
private int id;
@NonNull
private String label;
@NonNull
private String url;
private boolean enabled = true;
private boolean allowEnabled = false;
private boolean redirectEnabled = false;
@ColumnInfo(name = "last_modified_local")
private ZonedDateTime localModificationDate;
@ColumnInfo(name = "last_modified_online")
private ZonedDateTime onlineModificationDate;
/**
* The HTTP ETag (strong from, may be <code>null</code>).
*/
private String entityTag;
/**
* The number of hosts list items (<code>0</code> until synced).
*/
private int size;
/**
* Check whether an URL is valid for as host source.<br>
* A valid URL is a HTTPS URL or file URL.
*
* @param url The URL to check.
* @return {@code true} if the URL is valid, {@code false} otherwise.
*/
public static boolean isValidUrl(String url) {
return (!"https://".equals(url) && URLUtil.isHttpsUrl(url)) || URLUtil.isContentUrl(url);
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
@NonNull
public String getLabel() {
return label;
}
public void setLabel(@NonNull String label) {
this.label = label;
}
@NonNull
public String getUrl() {
return url;
}
public void setUrl(@NonNull String url) {
this.url = url;
}
public SourceType getType() {
if (this.url.startsWith("https://")) {
return URL;
} else if (this.url.startsWith("content://")) {
return FILE;
} else {
return UNSUPPORTED;
}
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isAllowEnabled() {
return allowEnabled;
}
public void setAllowEnabled(boolean allowEnabled) {
this.allowEnabled = allowEnabled;
}
public boolean isRedirectEnabled() {
return redirectEnabled;
}
public void setRedirectEnabled(boolean redirectEnabled) {
this.redirectEnabled = redirectEnabled;
}
public ZonedDateTime getLocalModificationDate() {
return localModificationDate;
}
public void setLocalModificationDate(ZonedDateTime localModificationDate) {
this.localModificationDate = localModificationDate;
}
public ZonedDateTime getOnlineModificationDate() {
return onlineModificationDate;
}
public void setOnlineModificationDate(ZonedDateTime lastOnlineModification) {
this.onlineModificationDate = lastOnlineModification;
}
public String getEntityTag() {
return this.entityTag;
}
public void setEntityTag(String entityTag) {
this.entityTag = entityTag;
}
public int getSize() {
return this.size;
}
public void setSize(int size) {
this.size = size;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
HostsSource that = (HostsSource) o;
if (id != that.id) return false;
if (enabled != that.enabled) return false;
if (!url.equals(that.url)) return false;
if (!Objects.equals(localModificationDate, that.localModificationDate))
return false;
return Objects.equals(onlineModificationDate, that.onlineModificationDate);
}
@Override
public int hashCode() {
int result = id;
result = 31 * result + url.hashCode();
result = 31 * result + (enabled ? 1 : 0);
result = 31 * result + (localModificationDate != null ? localModificationDate.hashCode() : 0);
result = 31 * result + (onlineModificationDate != null ? onlineModificationDate.hashCode() : 0);
return result;
}
}

View file

@ -0,0 +1,31 @@
package org.adaway.db.entity;
/**
* This enumerate specifies the type of {@link HostListItem}.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public enum ListType {
BLOCKED(0),
ALLOWED(1),
REDIRECTED(2);
private final int value;
ListType(int value) {
this.value = value;
}
public static ListType fromValue(int value) {
for (ListType listType : ListType.values()) {
if (listType.value == value) {
return listType;
}
}
throw new IllegalArgumentException("Invalid value for list type: " + value);
}
public int getValue() {
return value;
}
}

View file

@ -0,0 +1,21 @@
package org.adaway.db.entity;
/**
* This enumerate specifies the type of {@link HostsSource}.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public enum SourceType {
/**
* The URL type represents online source to download from URL.
*/
URL,
/**
* The FILE type represents file stored source to retrieve using SAF API.
*/
FILE,
/**
* The UNSUPPORTED type represents unhandled source type.
*/
UNSUPPORTED
}

View file

@ -0,0 +1,167 @@
package org.adaway.helper;
import static android.app.PendingIntent.FLAG_IMMUTABLE;
import static android.app.PendingIntent.getActivity;
import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK;
import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
import static androidx.core.app.NotificationCompat.PRIORITY_LOW;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import org.adaway.R;
import org.adaway.ui.home.HomeActivity;
import org.adaway.ui.update.UpdateActivity;
/**
* This class is an helper class to deals with notifications.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class NotificationHelper {
/**
* The notification channel for updates.
*/
public static final String UPDATE_NOTIFICATION_CHANNEL = "UpdateChannel";
/**
* The notification channel for VPN service.
*/
public static final String VPN_SERVICE_NOTIFICATION_CHANNEL = "VpnServiceChannel";
/**
* The update hosts notification identifier.
*/
private static final int UPDATE_HOSTS_NOTIFICATION_ID = 10;
/**
* The update application notification identifier.
*/
private static final int UPDATE_APP_NOTIFICATION_ID = 11;
/**
* The VPN running service notification identifier.
*/
public static final int VPN_RUNNING_SERVICE_NOTIFICATION_ID = 20;
/**
* The VPN resume service notification identifier.
*/
public static final int VPN_RESUME_SERVICE_NOTIFICATION_ID = 21;
/**
* Private constructor.
*/
private NotificationHelper() {
}
/**
* Create the application notification channel.
*
* @param context The application context.
*/
public static void createNotificationChannels(@NonNull Context context) {
// Create update notification channel
NotificationChannel updateChannel = new NotificationChannel(
UPDATE_NOTIFICATION_CHANNEL,
context.getString(R.string.notification_update_channel_name),
NotificationManager.IMPORTANCE_LOW
);
updateChannel.setDescription(context.getString(R.string.notification_update_channel_description));
// Create VPN service notification channel
NotificationChannel vpnServiceChannel = new NotificationChannel(
VPN_SERVICE_NOTIFICATION_CHANNEL,
context.getString(R.string.notification_vpn_channel_name),
NotificationManager.IMPORTANCE_LOW
);
updateChannel.setDescription(context.getString(R.string.notification_vpn_channel_description));
// Register the channels with the system; you can't change the importance or other notification behaviors after this
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
if (notificationManager != null) {
notificationManager.createNotificationChannel(updateChannel);
notificationManager.createNotificationChannel(vpnServiceChannel);
}
}
/**
* Show the notification about new hosts update available.
*
* @param context The application context.
*/
public static void showUpdateHostsNotification(@NonNull Context context) {
// Get notification manager
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
if (notificationManager == null || !notificationManager.areNotificationsEnabled()) {
return;
}
// Build notification
int color = context.getColor(R.color.notification);
String title = context.getString(R.string.notification_update_host_available_title);
String text = context.getString(R.string.notification_update_host_available_text);
Intent intent = new Intent(context, HomeActivity.class);
intent.setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK);
PendingIntent pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, UPDATE_NOTIFICATION_CHANNEL)
.setSmallIcon(R.drawable.logo)
.setColorized(true)
.setColor(color)
.setShowWhen(false)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setPriority(PRIORITY_LOW)
.setAutoCancel(true);
// Notify the built notification
notificationManager.notify(UPDATE_HOSTS_NOTIFICATION_ID, builder.build());
}
/**
* Show the notification about new application update available.
*
* @param context The application context.
*/
public static void showUpdateApplicationNotification(@NonNull Context context) {
// Get notification manager
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
if (notificationManager == null || !notificationManager.areNotificationsEnabled()) {
return;
}
// Build notification
int color = context.getColor(R.color.notification);
String title = context.getString(R.string.notification_update_app_available_title);
String text = context.getString(R.string.notification_update_app_available_text);
Intent intent = new Intent(context, UpdateActivity.class);
intent.setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK);
PendingIntent pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, UPDATE_NOTIFICATION_CHANNEL)
.setSmallIcon(R.drawable.logo)
.setColorized(true)
.setColor(color)
.setShowWhen(false)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setPriority(PRIORITY_LOW)
.setAutoCancel(true);
// Notify the built notification
notificationManager.notify(UPDATE_HOSTS_NOTIFICATION_ID, builder.build());
}
/**
* Hide the notification about new hosts update available.
*
* @param context The application context.
*/
public static void clearUpdateNotifications(@NonNull Context context) {
// Get notification manager
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
if (notificationManager == null) {
return;
}
// Cancel the notification
notificationManager.cancel(UPDATE_HOSTS_NOTIFICATION_ID);
notificationManager.cancel(UPDATE_APP_NOTIFICATION_ID);
}
}

View file

@ -0,0 +1,361 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.helper;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.appcompat.app.AppCompatDelegate;
import org.adaway.R;
import org.adaway.model.adblocking.AdBlockMethod;
import org.adaway.util.Constants;
import org.adaway.vpn.VpnStatus;
import java.util.Collections;
import java.util.Set;
public final class PreferenceHelper {
private PreferenceHelper() {
}
public static int getDarkThemeMode(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
String pref = prefs.getString(
context.getString(R.string.pref_dark_theme_mode_key),
context.getResources().getString(R.string.pref_dark_theme_mode_def)
);
switch (pref) {
case "MODE_NIGHT_NO":
return AppCompatDelegate.MODE_NIGHT_NO;
case "MODE_NIGHT_YES":
return AppCompatDelegate.MODE_NIGHT_YES;
default:
return AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM;
}
}
public static boolean getUpdateCheck(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_update_check_key),
context.getResources().getBoolean(R.bool.pref_update_check_def)
);
}
public static boolean getNeverReboot(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_never_reboot_key),
context.getResources().getBoolean(R.bool.pref_never_reboot_def)
);
}
public static void setNeverReboot(Context context, boolean value) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(context.getString(R.string.pref_never_reboot_key), value);
editor.apply();
}
public static boolean getEnableIpv6(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_enable_ipv6_key),
context.getResources().getBoolean(R.bool.pref_enable_ipv6_def)
);
}
public static boolean getUpdateCheckAppStartup(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_update_check_app_startup_key),
context.getResources().getBoolean(R.bool.pref_update_check_app_startup_def)
);
}
public static boolean getUpdateCheckAppDaily(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_update_check_app_daily_key),
context.getResources().getBoolean(R.bool.pref_update_check_app_daily_def)
);
}
public static boolean getIncludeBetaReleases(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_update_include_beta_releases_key),
context.getResources().getBoolean(R.bool.pref_update_include_beta_releases_def)
);
}
public static boolean getUpdateCheckHostsDaily(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_update_check_hosts_daily_key),
context.getResources().getBoolean(R.bool.pref_update_check_hosts_daily_def)
);
}
public static boolean getAutomaticUpdateDaily(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_automatic_update_daily_key),
context.getResources().getBoolean(R.bool.pref_automatic_update_daily_def)
);
}
public static boolean getUpdateOnlyOnWifi(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_update_only_on_wifi_key),
context.getResources().getBoolean(R.bool.pref_update_only_on_wifi_def)
);
}
public static String getRedirectionIpv4(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getString(
context.getString(R.string.pref_redirection_ipv4_key),
context.getString(R.string.pref_redirection_ipv4_def)
);
}
public static String getRedirectionIpv6(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getString(
context.getString(R.string.pref_redirection_ipv6_key),
context.getString(R.string.pref_redirection_ipv6_def)
);
}
public static boolean getWebServerEnabled(Context context) {
SharedPreferences prefs = context.getApplicationContext().getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_webserver_enabled_key),
context.getResources().getBoolean(R.bool.pref_webserver_enabled_def)
);
}
public static boolean getWebServerIcon(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_webserver_icon_key),
context.getResources().getBoolean(R.bool.pref_webserver_icon_def)
);
}
public static AdBlockMethod getAdBlockMethod(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return AdBlockMethod.fromCode(prefs.getInt(
context.getString(R.string.pref_ad_block_method_key),
context.getResources().getInteger(R.integer.pref_ad_block_method_key_def)
));
}
public static void setAbBlockMethod(Context context, AdBlockMethod method) {
SharedPreferences prefs = context.getApplicationContext().getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
SharedPreferences.Editor editor = prefs.edit();
editor.putInt(context.getString(R.string.pref_ad_block_method_key), method.toCode());
editor.apply();
}
public static VpnStatus getVpnServiceStatus(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return VpnStatus.fromCode(prefs.getInt(
context.getString(R.string.pref_vpn_service_status_key),
context.getResources().getInteger(R.integer.pref_vpn_service_status_def)
));
}
public static void setVpnServiceStatus(Context context, VpnStatus status) {
SharedPreferences prefs = context.getApplicationContext().getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
SharedPreferences.Editor editor = prefs.edit();
editor.putInt(context.getString(R.string.pref_vpn_service_status_key), status.toCode());
editor.apply();
}
public static boolean getVpnServiceOnBoot(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_vpn_service_on_boot_key),
context.getResources().getBoolean(R.bool.pref_vpn_service_on_boot_def)
);
}
public static boolean getVpnWatchdogEnabled(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_vpn_watchdog_enabled_key),
context.getResources().getBoolean(R.bool.pref_vpn_watchdog_enabled_def)
);
}
public static boolean getDebugEnabled(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_enable_debug_key),
context.getResources().getBoolean(R.bool.pref_enable_debug_def)
);
}
public static boolean getTelemetryEnabled(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_enable_telemetry_key),
context.getResources().getBoolean(R.bool.pref_enable_telemetry_def)
);
}
public static void setTelemetryEnabled(Context context, boolean enabled) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(context.getString(R.string.pref_enable_telemetry_key), enabled);
editor.apply();
}
public static boolean getDisplayTelemetryConsent(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getBoolean(
context.getString(R.string.pref_display_telemetry_consent_key),
context.getResources().getBoolean(R.bool.pref_display_telemetry_consent_def)
);
}
public static void setDisplayTelemetryConsent(Context context, boolean displayTelemetryConsent) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(context.getString(R.string.pref_display_telemetry_consent_key), displayTelemetryConsent);
editor.apply();
}
public static String getVpnExcludedSystemApps(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getString(
context.getString(R.string.pref_vpn_excluded_system_apps_key),
context.getString(R.string.pref_vpn_excluded_system_apps_default)
);
}
public static Set<String> getVpnExcludedApps(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
return prefs.getStringSet(
context.getString(R.string.pref_vpn_excluded_user_apps_key),
Collections.emptySet()
);
}
public static void setVpnExcludedApps(Context context, Set<String> excludedApplicationPackageNames) {
SharedPreferences prefs = context.getSharedPreferences(
Constants.PREFS_NAME,
Context.MODE_PRIVATE
);
SharedPreferences.Editor editor = prefs.edit();
editor.putStringSet(context.getString(R.string.pref_vpn_excluded_user_apps_key), excludedApplicationPackageNames);
editor.apply();
}
}

View file

@ -0,0 +1,29 @@
package org.adaway.helper;
import android.content.Context;
import androidx.appcompat.app.AppCompatDelegate;
/**
* This class is a helper to apply user selected theme on the application activity.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class ThemeHelper {
/**
* Private constructor.
*/
private ThemeHelper() {
}
/**
* Apply the user selected theme.
*
* @param context The context to apply theme.
*/
public static void applyTheme(Context context) {
AppCompatDelegate.setDefaultNightMode(PreferenceHelper.getDarkThemeMode(context));
}
}

View file

@ -0,0 +1,40 @@
package org.adaway.model.adblocking;
import java.util.Arrays;
/**
* This enum represents the ad blocking methods.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public enum AdBlockMethod {
/**
* Not defined ad block method.
*/
UNDEFINED(0),
/**
* The system hosts file ad block method.
*/
ROOT(1),
/**
* The VPN based ad block method.
*/
VPN(2);
private int code;
AdBlockMethod(int code) {
this.code = code;
}
public static AdBlockMethod fromCode(int code) {
return Arrays.stream(AdBlockMethod.values())
.filter(method -> method.code == code)
.findAny()
.orElse(UNDEFINED);
}
public int toCode() {
return this.code;
}
}

View file

@ -0,0 +1,140 @@
package org.adaway.model.adblocking;
import android.content.Context;
import androidx.annotation.StringRes;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.adaway.model.error.HostErrorException;
import org.adaway.model.root.RootModel;
import org.adaway.model.vpn.VpnModel;
import java.util.List;
import timber.log.Timber;
/**
* This class is the base model for all ad block model.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public abstract class AdBlockModel {
/**
* The application context.
*/
protected final Context context;
/**
* The hosts installation status:
* <ul>
* <li>{@code null} if not defined,</li>
* <li>{@code true} if hosts list is installed,</li>
* <li>{@code false} if default hosts file.</li>
* </ul>
*/
protected final MutableLiveData<Boolean> applied;
/**
* The model state.
*/
private final MutableLiveData<String> state;
/**
* Constructor.
*
* @param context The application context.
*/
protected AdBlockModel(Context context) {
this.context = context;
this.state = new MutableLiveData<>();
this.applied = new MutableLiveData<>();
}
/**
* Instantiate ad block model.
*
* @param context The application context.
* @param method The ad block method to get model.
* @return The instantiated model.
*/
public static AdBlockModel build(Context context, AdBlockMethod method) {
switch (method) {
case ROOT:
return new RootModel(context);
case VPN:
return new VpnModel(context);
default:
return new UndefinedBlockModel(context);
}
}
/**
* Get ad block method.
*
* @return The ad block method of this model.
*/
public abstract AdBlockMethod getMethod();
/**
* Checks if hosts list is applied.
*
* @return {@code true} if applied, {@code false} if default.
*/
public LiveData<Boolean> isApplied() {
return this.applied;
}
/**
* Apply hosts list.
*
* @throws HostErrorException If the model configuration could not be applied.
*/
public abstract void apply() throws HostErrorException;
/**
* Revert the hosts list to the default one.
*
* @throws HostErrorException If the model configuration could not be revert.
*/
public abstract void revert() throws HostErrorException;
/**
* Get the model state.
*
* @return The model state.
*/
public LiveData<String> getState() {
return this.state;
}
protected void setState(@StringRes int stateResId, Object... details) {
String state = this.context.getString(stateResId, details);
Timber.d(state);
this.state.postValue(state);
}
/**
* Get whether log are recoding or not.
*
* @return {@code true} if logs are recoding, {@code false} otherwise.
*/
public abstract boolean isRecordingLogs();
/**
* Set log recoding.
*
* @param recording {@code true} to record logs, {@code false} otherwise.
*/
public abstract void setRecordingLogs(boolean recording);
/**
* Get logs.
*
* @return The logs unique and sorted by date, older first.
*/
public abstract List<String> getLogs();
/**
* Clear logs.
*/
public abstract void clearLogs();
}

View file

@ -0,0 +1,59 @@
package org.adaway.model.adblocking;
import static org.adaway.model.adblocking.AdBlockMethod.UNDEFINED;
import static java.util.Collections.emptyList;
import android.content.Context;
import java.util.List;
/**
* This class is a stub model when no ad block method is defined.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class UndefinedBlockModel extends AdBlockModel {
/**
* Constructor.
*
* @param context The application context.
*/
public UndefinedBlockModel(Context context) {
super(context);
}
@Override
public AdBlockMethod getMethod() {
return UNDEFINED;
}
@Override
public void apply() {
// Unsupported operation
}
@Override
public void revert() {
// Unsupported operation
}
@Override
public boolean isRecordingLogs() {
return false;
}
@Override
public void setRecordingLogs(boolean recording) {
// Unsupported operation
}
@Override
public List<String> getLogs() {
return emptyList();
}
@Override
public void clearLogs() {
// Unsupported operation
}
}

View file

@ -0,0 +1,82 @@
package org.adaway.model.backup;
import static org.adaway.util.Constants.PREFS_NAME;
import android.app.backup.BackupAgentHelper;
import android.app.backup.BackupDataInputStream;
import android.app.backup.BackupDataOutput;
import android.app.backup.FileBackupHelper;
import android.app.backup.SharedPreferencesBackupHelper;
import android.content.Context;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import java.io.File;
import java.io.IOException;
import timber.log.Timber;
/**
* This class is a {@link android.app.backup.BackupAgent} to backup and restore application state
* using Android Backup Service. It is based on key-value pairs backup to prevent killing the
* application during the backup (leaving the VPN foreground service always running). It backs up
* and restores the application preferences and the user rules.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class AppBackupAgent extends BackupAgentHelper {
private static final String PREFS_BACKUP_KEY = "prefs";
private static final String RULES_BACKUP_KEY = "rules";
@Override
public void onCreate() {
super.onCreate();
addHelper(PREFS_BACKUP_KEY, new SharedPreferencesBackupHelper(this, PREFS_NAME));
addHelper(RULES_BACKUP_KEY, new SourceBackupHelper(this));
}
/**
* This class is a {@link android.app.backup.BackupHelper} to backup and restore user rules.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
private static class SourceBackupHelper extends FileBackupHelper {
private static final String RULES_FILE_NAME = "rules-backup.json";
private final Context context;
/**
* Constructor.
*
* @param context The application context.
*/
public SourceBackupHelper(Context context) {
super(context, RULES_FILE_NAME);
this.context = context;
}
@Override
public void performBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) {
try {
BackupExporter.exportBackup(this.context, getRulesFileUri());
super.performBackup(oldState, data, newState);
} catch (IOException e) {
Timber.w(e, "Failed to export rules to backup.");
}
}
@Override
public void restoreEntity(BackupDataInputStream data) {
super.restoreEntity(data);
try {
BackupImporter.importBackup(this.context, getRulesFileUri());
} catch (IOException e) {
Timber.w(e, "Failed to import rules from backup.");
}
}
private Uri getRulesFileUri() {
File ruleFile = new File(this.context.getFilesDir(), RULES_FILE_NAME);
return Uri.fromFile(ruleFile);
}
}
}

View file

@ -0,0 +1,141 @@
package org.adaway.model.backup;
import android.content.Context;
import android.net.Uri;
import android.widget.Toast;
import androidx.annotation.UiThread;
import org.adaway.R;
import org.adaway.db.AppDatabase;
import org.adaway.db.dao.HostListItemDao;
import org.adaway.db.dao.HostsSourceDao;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.HostsSource;
import org.adaway.util.AppExecutors;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.util.List;
import java.util.concurrent.Executor;
import static java.util.stream.Collectors.toList;
import static org.adaway.db.entity.ListType.ALLOWED;
import static org.adaway.db.entity.ListType.BLOCKED;
import static org.adaway.db.entity.ListType.REDIRECTED;
import static org.adaway.model.backup.BackupFormat.ALLOWED_KEY;
import static org.adaway.model.backup.BackupFormat.BLOCKED_KEY;
import static org.adaway.model.backup.BackupFormat.REDIRECTED_KEY;
import static org.adaway.model.backup.BackupFormat.SOURCES_KEY;
import static org.adaway.model.backup.BackupFormat.hostToJson;
import static org.adaway.model.backup.BackupFormat.sourceToJson;
import timber.log.Timber;
/**
* This class is a helper class to export user lists and hosts sources to a backup file.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class BackupExporter {
private static final Executor DISK_IO_EXECUTOR = AppExecutors.getInstance().diskIO();
private static final Executor MAIN_THREAD_EXECUTOR = AppExecutors.getInstance().mainThread();
private BackupExporter() {
}
/**
* Export all user lists and hosts sources to a backup file on the external storage.
*
* @param context The application context.
*/
public static void exportToBackup(Context context, Uri backupUri) {
DISK_IO_EXECUTOR.execute(() -> {
boolean imported = true;
try {
exportBackup(context, backupUri);
} catch (IOException e) {
Timber.e(e, "Failed to import backup.");
imported = false;
}
boolean successful = imported;
String fileName = getFileNameFromUri(backupUri);
MAIN_THREAD_EXECUTOR.execute(() -> notifyExportEnd(context, successful, fileName));
});
}
private static String getFileNameFromUri(Uri backupUri) {
String path = backupUri.getPath();
return path == null ? "" : new File(path).getName();
}
@UiThread
private static void notifyExportEnd(Context context, boolean successful, String backupUri) {
Toast.makeText(
context,
context.getString(successful ? R.string.export_success : R.string.export_failed, backupUri),
Toast.LENGTH_LONG
).show();
}
static void exportBackup(Context context, Uri backupUri) throws IOException {
// Open writer on the export file
try (OutputStream outputStream = context.getContentResolver().openOutputStream(backupUri);
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream))) {
JSONObject backup = makeBackup(context);
writer.write(backup.toString(4));
} catch (JSONException e) {
throw new IOException("Failed to generate backup.", e);
} catch (IOException e) {
throw new IOException("Could not write file.", e);
}
}
private static JSONObject makeBackup(Context context) throws JSONException {
AppDatabase database = AppDatabase.getInstance(context);
HostsSourceDao hostsSourceDao = database.hostsSourceDao();
HostListItemDao hostListItemDao = database.hostsListItemDao();
List<HostListItem> userHosts = hostListItemDao.getUserList();
List<HostListItem> blockedHosts = userHosts.stream()
.filter(value -> value.getType() == BLOCKED)
.collect(toList());
List<HostListItem> allowedHosts = userHosts.stream()
.filter(value -> value.getType() == ALLOWED)
.collect(toList());
List<HostListItem> redirectedHosts = userHosts.stream()
.filter(value -> value.getType() == REDIRECTED)
.collect(toList());
JSONObject backupObject = new JSONObject();
backupObject.put(SOURCES_KEY, buildSourcesBackup(hostsSourceDao.getAll()));
backupObject.put(BLOCKED_KEY, buildListBackup(blockedHosts));
backupObject.put(ALLOWED_KEY, buildListBackup(allowedHosts));
backupObject.put(REDIRECTED_KEY, buildListBackup(redirectedHosts));
return backupObject;
}
private static JSONArray buildSourcesBackup(List<HostsSource> sources) throws JSONException {
JSONArray sourceArray = new JSONArray();
for (HostsSource source : sources) {
sourceArray.put(sourceToJson(source));
}
return sourceArray;
}
private static JSONArray buildListBackup(List<HostListItem> hosts) throws JSONException {
JSONArray listArray = new JSONArray();
for (HostListItem host : hosts) {
listArray.put(hostToJson(host));
}
return listArray;
}
}

View file

@ -0,0 +1,84 @@
package org.adaway.model.backup;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.HostsSource;
import org.json.JSONException;
import org.json.JSONObject;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
/**
* This class defines user lists and hosts sources file format.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
final class BackupFormat {
/*
* Source backup format.
*/
static final String SOURCES_KEY = "sources";
static final String SOURCE_LABEL_ATTRIBUTE = "label";
static final String SOURCE_URL_ATTRIBUTE = "url";
static final String SOURCE_ENABLED_ATTRIBUTE = "enabled";
static final String SOURCE_ALLOW_ATTRIBUTE = "allow";
static final String SOURCE_REDIRECT_ATTRIBUTE = "redirect";
/*
* User source backup format.
*/
static final String BLOCKED_KEY = "blocked";
static final String ALLOWED_KEY = "allowed";
static final String REDIRECTED_KEY = "redirected";
static final String ENABLED_ATTRIBUTE = "enabled";
static final String HOST_ATTRIBUTE = "host";
static final String REDIRECT_ATTRIBUTE = "redirect";
BackupFormat() {
}
static JSONObject sourceToJson(HostsSource source) throws JSONException {
JSONObject sourceObject = new JSONObject();
sourceObject.put(SOURCE_LABEL_ATTRIBUTE, source.getLabel());
sourceObject.put(SOURCE_URL_ATTRIBUTE, source.getUrl());
sourceObject.put(SOURCE_ENABLED_ATTRIBUTE, source.isEnabled());
sourceObject.put(SOURCE_ALLOW_ATTRIBUTE, source.isAllowEnabled());
sourceObject.put(SOURCE_REDIRECT_ATTRIBUTE, source.isRedirectEnabled());
return sourceObject;
}
static HostsSource sourceFromJson(JSONObject sourceObject) throws JSONException {
HostsSource source = new HostsSource();
source.setLabel(sourceObject.getString(SOURCE_LABEL_ATTRIBUTE));
String url = sourceObject.getString(SOURCE_URL_ATTRIBUTE);
if (!HostsSource.isValidUrl(url)) {
throw new JSONException("Invalid source URL: "+url);
}
source.setUrl(url);
source.setEnabled(sourceObject.getBoolean(SOURCE_ENABLED_ATTRIBUTE));
source.setAllowEnabled(sourceObject.getBoolean(SOURCE_ALLOW_ATTRIBUTE));
source.setRedirectEnabled(sourceObject.getBoolean(SOURCE_REDIRECT_ATTRIBUTE));
return source;
}
static JSONObject hostToJson(HostListItem host) throws JSONException {
JSONObject hostObject = new JSONObject();
hostObject.put(HOST_ATTRIBUTE, host.getHost());
String redirection = host.getRedirection();
if (redirection != null && !redirection.isEmpty()) {
hostObject.put(REDIRECT_ATTRIBUTE, redirection);
}
hostObject.put(ENABLED_ATTRIBUTE, host.isEnabled());
return hostObject;
}
static HostListItem hostFromJson(JSONObject hostObject) throws JSONException {
HostListItem host = new HostListItem();
host.setHost(hostObject.getString(HOST_ATTRIBUTE));
if (hostObject.has(REDIRECT_ATTRIBUTE)) {
host.setRedirection(hostObject.getString(REDIRECT_ATTRIBUTE));
}
host.setEnabled(hostObject.getBoolean(ENABLED_ATTRIBUTE));
host.setSourceId(USER_SOURCE_ID);
return host;
}
}

View file

@ -0,0 +1,136 @@
package org.adaway.model.backup;
import android.content.Context;
import android.net.Uri;
import android.widget.Toast;
import androidx.annotation.UiThread;
import org.adaway.R;
import org.adaway.db.AppDatabase;
import org.adaway.db.dao.HostListItemDao;
import org.adaway.db.dao.HostsSourceDao;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.ListType;
import org.adaway.util.AppExecutors;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Optional;
import java.util.concurrent.Executor;
import static org.adaway.db.entity.ListType.ALLOWED;
import static org.adaway.db.entity.ListType.BLOCKED;
import static org.adaway.db.entity.ListType.REDIRECTED;
import static org.adaway.model.backup.BackupFormat.ALLOWED_KEY;
import static org.adaway.model.backup.BackupFormat.BLOCKED_KEY;
import static org.adaway.model.backup.BackupFormat.REDIRECTED_KEY;
import static org.adaway.model.backup.BackupFormat.SOURCES_KEY;
import static org.adaway.model.backup.BackupFormat.hostFromJson;
import static org.adaway.model.backup.BackupFormat.sourceFromJson;
import timber.log.Timber;
/**
* This class is a helper class to import user lists and hosts sources to a backup file.<br>
* Importing a file source will no restore read access from Storage Access Framework.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class BackupImporter {
private BackupImporter() {
}
private static final Executor DISK_IO_EXECUTOR = AppExecutors.getInstance().diskIO();
private static final Executor MAIN_THREAD_EXECUTOR = AppExecutors.getInstance().mainThread();
/**
* Import a backup file.
*
* @param context The application context.
* @param backupUri The URI of a backup file.
*/
@UiThread
public static void importFromBackup(Context context, Uri backupUri) {
DISK_IO_EXECUTOR.execute(() -> {
boolean imported = true;
try {
importBackup(context, backupUri);
} catch (IOException e) {
Timber.e(e, "Failed to import backup.");
imported = false;
}
boolean successful = imported;
MAIN_THREAD_EXECUTOR.execute(() -> notifyImportEnd(context, successful));
});
}
@UiThread
private static void notifyImportEnd(Context context, boolean successful) {
Toast.makeText(
context,
context.getString(successful ? R.string.import_success : R.string.import_failed),
Toast.LENGTH_LONG
).show();
}
static void importBackup(Context context, Uri backupUri) throws IOException {
try (InputStream inputStream = context.getContentResolver().openInputStream(backupUri);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
StringBuilder contentBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
contentBuilder.append(line);
}
JSONObject backupObject = new JSONObject(contentBuilder.toString());
importBackup(context, backupObject);
} catch (JSONException exception) {
throw new IOException("Failed to parse backup file.", exception);
} catch (FileNotFoundException exception) {
throw new IOException("Failed to find backup file.", exception);
} catch (IOException exception) {
throw new IOException("Failed to read backup file.", exception);
}
}
private static void importBackup(Context context, JSONObject backupObject) throws JSONException {
AppDatabase database = AppDatabase.getInstance(context);
HostsSourceDao hostsSourceDao = database.hostsSourceDao();
HostListItemDao hostListItemDao = database.hostsListItemDao();
importSourceBackup(hostsSourceDao, backupObject.getJSONArray(SOURCES_KEY));
importListBackup(hostListItemDao, BLOCKED, backupObject.getJSONArray(BLOCKED_KEY));
importListBackup(hostListItemDao, ALLOWED, backupObject.getJSONArray(ALLOWED_KEY));
importListBackup(hostListItemDao, REDIRECTED, backupObject.getJSONArray(REDIRECTED_KEY));
}
private static void importSourceBackup(HostsSourceDao hostsSourceDao, JSONArray sources) throws JSONException {
for (int index = 0; index < sources.length(); index++) {
JSONObject sourceObject = sources.getJSONObject(index);
hostsSourceDao.insert(sourceFromJson(sourceObject));
}
}
private static void importListBackup(HostListItemDao hostListItemDao, ListType type, JSONArray hosts) throws JSONException {
for (int index = 0; index < hosts.length(); index++) {
JSONObject hostObject = hosts.getJSONObject(index);
HostListItem host = hostFromJson(hostObject);
host.setType(type);
Optional<Integer> id = hostListItemDao.getHostId(host.getHost());
if (id.isPresent()) {
host.setId(id.get());
hostListItemDao.update(host);
} else {
hostListItemDao.insert(host);
}
}
}
}

View file

@ -0,0 +1,44 @@
package org.adaway.model.error;
import androidx.annotation.StringRes;
import org.adaway.R;
/**
* This enumeration represents the hosts error case.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public enum HostError {
// Source model errors
NO_CONNECTION(R.string.error_no_connection_message, R.string.error_no_connection_details),
DOWNLOAD_FAILED(R.string.error_download_failed_message, R.string.error_no_connection_details),
// Host install model errors
PRIVATE_FILE_FAILED(R.string.error_private_file_failed_message, R.string.error_private_file_failed_details),
NOT_ENOUGH_SPACE(R.string.error_not_enough_space_message, R.string.error_not_enough_space_details),
COPY_FAIL(R.string.error_copy_failed_message, R.string.error_copy_failed_details),
REVERT_FAIL(R.string.error_revert_failed_message, R.string.error_revert_failed_details),
// VPN model error
ENABLE_VPN_FAIL(R.string.error_enable_vpn_failed_message, R.string.error_enable_vpn_failed_details),
DISABLE_VPN_FAIL(R.string.error_disable_vpn_failed_message, R.string.error_disable_vpn_failed_details);
@StringRes
private final int messageKey;
@StringRes
private final int detailsKey;
HostError(int messageKey, int detailsKey) {
this.messageKey = messageKey;
this.detailsKey = detailsKey;
}
@StringRes
public int getMessageKey() {
return this.messageKey;
}
@StringRes
public int getDetailsKey() {
return this.detailsKey;
}
}

View file

@ -0,0 +1,43 @@
package org.adaway.model.error;
/**
* This class in an {@link Exception} thrown on hosts error.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class HostErrorException extends Exception {
/**
* The exception error type.
*/
private final HostError error;
/**
* Constructor.
*
* @param error The exception error type.
*/
public HostErrorException(HostError error) {
super("An host error " + error.name() + " occurred");
this.error = error;
}
/**
* Constructor.
*
* @param error The exception error type.
* @param cause The cause of this exception.
*/
public HostErrorException(HostError error, Throwable cause) {
super("An host error " + error.name() + " occurred", cause);
this.error = error;
}
/**
* Get the error type.
*
* @return The exception error type
*/
public HostError getError() {
return this.error;
}
}

View file

@ -0,0 +1,61 @@
package org.adaway.model.git;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import timber.log.Timber;
/**
* This class is an utility class to get information from GitHub gist hosting.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
class GistHostsSource extends GitHostsJsonApiSource {
/**
* The gist identifier.
*/
private final String gistIdentifier;
/**
* Constructor.
*
* @param url The hosts file URL hosted on GitHub gist.
* @throws MalformedURLException If the URl is not a gist URL.
*/
GistHostsSource(String url) throws MalformedURLException {
// Check URL path
URL parsedUrl = new URL(url);
String path = parsedUrl.getPath();
String[] pathParts = path.split("/");
if (pathParts.length < 2) {
throw new MalformedURLException("The GitHub gist URL " + url + " is not valid.");
}
// Extract gist identifier from path
this.gistIdentifier = pathParts[2];
}
@Override
protected String getCommitApiUrl() {
return "https://api.github.com/gists/" + this.gistIdentifier;
}
@Nullable
protected ZonedDateTime parseJsonBody(String body) throws JSONException {
JSONObject gistObject = new JSONObject(body);
String dateString = gistObject.getString("updated_at");
ZonedDateTime date = null;
try {
date = ZonedDateTime.parse(dateString);
} catch (DateTimeParseException exception) {
Timber.w(exception, "Failed to parse commit date: " + dateString + ".");
}
return date;
}
}

View file

@ -0,0 +1,53 @@
package org.adaway.model.git;
import androidx.annotation.Nullable;
import org.json.JSONException;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.time.ZonedDateTime;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import timber.log.Timber;
/**
* This class is an utility class to get information from Git hosted hosts sources from JSON API.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public abstract class GitHostsJsonApiSource extends GitHostsSource {
@Override
@Nullable
public ZonedDateTime getLastUpdate() {
return getLastUpdateFromApi(getCommitApiUrl());
}
protected abstract String getCommitApiUrl();
@Nullable
protected ZonedDateTime getLastUpdateFromApi(String commitApiUrl) {
// Create client and request
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url(commitApiUrl).build();
try (Response response = client.newCall(request).execute();
ResponseBody body = response.body()) {
if (response.isSuccessful()) {
return parseJsonBody(body.string());
}
} catch (UnknownHostException | SocketTimeoutException exception) {
Timber.i(exception, "Unable to reach API backend.");
} catch (IOException | JSONException exception) {
Timber.e(exception, "Unable to get commits from API.");
}
// Return failed
return null;
}
@Nullable
protected abstract ZonedDateTime parseJsonBody(String body) throws JSONException;
}

View file

@ -0,0 +1,65 @@
package org.adaway.model.git;
import androidx.annotation.Nullable;
import java.net.MalformedURLException;
import java.time.ZonedDateTime;
/**
* This class is an utility class to get information from Git hosted hosts sources.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public abstract class GitHostsSource {
/**
* The GitHub repository URL.
*/
private static final String GITHUB_REPO_URL = "https://raw.githubusercontent.com/";
/**
* The GitHub gist URL.
*/
private static final String GITHUB_GIST_URL = "https://gist.githubusercontent.com";
/**
* The GitLab URL.
*/
private static final String GITLAB_URL = "https://gitlab.com/";
/**
* Check if a hosts file url is hosted on Git hosting.
*
* @param url The url to check.
* @return {@code true} if the hosts file is hosted on Git hosting, {@code false} otherwise.
*/
public static boolean isHostedOnGit(String url) {
return url.startsWith(GITHUB_REPO_URL) ||
url.startsWith(GITHUB_GIST_URL) ||
url.startsWith(GITLAB_URL);
}
/**
* Get the GitHub hosts source.
*
* @param url The URL to get source from.
* @return The GitHub hosts source.
* @throws MalformedURLException If the URL is not a GitHub URL or not a supported GitHub URL.
*/
public static GitHostsSource getSource(String url) throws MalformedURLException {
if (url.startsWith(GITHUB_REPO_URL)) {
return new GitHubHostsSource(url);
} else if (url.startsWith(GITHUB_GIST_URL)) {
return new GistHostsSource(url);
} else if (url.startsWith(GITLAB_URL)) {
return new GitLabHostsSource(url);
} else {
throw new MalformedURLException("URL is not a supported Git hosting URL");
}
}
/**
* Get last update of the hosts file.
*
* @return The last update date, {@code null} if the date could not be retrieved.
*/
@Nullable
public abstract ZonedDateTime getLastUpdate();
}

View file

@ -0,0 +1,84 @@
package org.adaway.model.git;
import static java.util.stream.Collectors.joining;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import timber.log.Timber;
/**
* This class is an utility class to get information from GitHub repository hosting.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
class GitHubHostsSource extends GitHostsJsonApiSource {
/**
* The GitHub owner name.
*/
private final String owner;
/**
* The GitHub repository name.
*/
private final String repo;
/**
* The GitHub blob (hosts file) path.
*/
private final String blobPath;
/**
* Constructor.
*
* @param url The hosts file URL hosted on GitHub.
* @throws MalformedURLException If the URl is not a GitHub URL.
*/
GitHubHostsSource(String url) throws MalformedURLException {
// Check URL path
URL parsedUrl = new URL(url);
String path = parsedUrl.getPath();
String[] pathParts = path.split("/");
if (pathParts.length < 5) {
throw new MalformedURLException("The GitHub user content URL " + url + " is not valid.");
}
// Extract components from path
this.owner = pathParts[1];
this.repo = pathParts[2];
this.blobPath = Arrays.stream(pathParts)
.skip(4)
.collect(joining("/"));
}
@Override
protected String getCommitApiUrl() {
return "https://api.github.com/repos/" + this.owner + "/" + this.repo +
"/commits?per_page=1&path=" + this.blobPath;
}
@Nullable
protected ZonedDateTime parseJsonBody(String body) throws JSONException {
JSONArray commitArray = new JSONArray(body);
int nbrOfCommits = commitArray.length();
ZonedDateTime date = null;
for (int i = 0; i < nbrOfCommits && date == null; i++) {
JSONObject commitItemObject = commitArray.getJSONObject(i);
JSONObject commitObject = commitItemObject.getJSONObject("commit");
JSONObject committerObject = commitObject.getJSONObject("committer");
String dateString = committerObject.getString("date");
try {
date = ZonedDateTime.parse(dateString);
} catch (DateTimeParseException exception) {
Timber.w(exception, "Failed to parse commit date: " + dateString + ".");
}
}
return date;
}
}

View file

@ -0,0 +1,81 @@
package org.adaway.model.git;
import static java.util.stream.Collectors.joining;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import timber.log.Timber;
/**
* This class is an utility class to get information from GitLab hosts source hosting.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class GitLabHostsSource extends GitHostsJsonApiSource {
/**
* The GitHub owner name.
*/
private final String owner;
/**
* The GitHub repository name.
*/
private final String repo;
/**
* The GitLab reference name.
*/
private final String ref;
/**
* The GitLab (hosts) file path.
*/
private final String path;
GitLabHostsSource(String url) throws MalformedURLException {
// Check URL path
URL parsedUrl = new URL(url);
String path = parsedUrl.getPath();
String[] pathParts = path.split("/");
if (pathParts.length < 5) {
throw new MalformedURLException("The GitLab user content URL " + url + " is not valid.");
}
// Extract components from path
this.owner = pathParts[1];
this.repo = pathParts[2];
this.ref = pathParts[4];
this.path = Arrays.stream(pathParts)
.skip(5)
.collect(joining("/"));
}
@Override
protected String getCommitApiUrl() {
return "https://gitlab.com/api/v4/projects/" + this.owner + "%2F" + this.repo
+ "/repository/commits?path=" + this.path + "&ref_name=" + this.ref;
}
@Nullable
protected ZonedDateTime parseJsonBody(String body) throws JSONException {
JSONArray commitArray = new JSONArray(body);
int nbrOfCommits = commitArray.length();
ZonedDateTime date = null;
for (int i = 0; i < nbrOfCommits && date == null; i++) {
JSONObject commitItemObject = commitArray.getJSONObject(i);
String dateString = commitItemObject.getString("committed_date");
try {
date = ZonedDateTime.parse(dateString);
} catch (DateTimeParseException exception) {
Timber.w(exception, "Failed to parse commit date: " + dateString + ".");
}
}
return date;
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.model.root;
class CommandException extends Exception {
private static final long serialVersionUID = -4014185620880841310L;
CommandException(String msg) {
super(msg);
}
}

View file

@ -0,0 +1,31 @@
package org.adaway.model.root;
/**
* This class is an enum to define mount type.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public enum MountType {
/**
* Mount as read only.
*/
READ_ONLY("ro"),
/**
* Mount as read/write.
*/
READ_WRITE("rw");
private final String option;
MountType(String option) {
this.option = option;
}
/**
* Get related command line option.
* @return The related command line option.
*/
public String getOption() {
return this.option;
}
}

View file

@ -0,0 +1,319 @@
package org.adaway.model.root;
import static android.content.Context.MODE_PRIVATE;
import static org.adaway.db.entity.ListType.REDIRECTED;
import static org.adaway.model.adblocking.AdBlockMethod.ROOT;
import static org.adaway.model.error.HostError.COPY_FAIL;
import static org.adaway.model.error.HostError.NOT_ENOUGH_SPACE;
import static org.adaway.model.error.HostError.PRIVATE_FILE_FAILED;
import static org.adaway.model.error.HostError.REVERT_FAIL;
import static org.adaway.model.root.ShellUtils.isWritable;
import static org.adaway.util.Constants.ANDROID_SYSTEM_ETC_HOSTS;
import static org.adaway.util.Constants.COMMAND_CHMOD_644;
import static org.adaway.util.Constants.COMMAND_CHOWN;
import static org.adaway.util.Constants.DEFAULT_HOSTS_FILENAME;
import static org.adaway.util.Constants.HOSTS_FILENAME;
import static org.adaway.util.Constants.LINE_SEPARATOR;
import static org.adaway.util.Constants.LOCALHOST_HOSTNAME;
import static org.adaway.util.Constants.LOCALHOST_IPV4;
import static org.adaway.util.Constants.LOCALHOST_IPV6;
import static org.adaway.model.root.MountType.READ_ONLY;
import static org.adaway.model.root.MountType.READ_WRITE;
import static org.adaway.model.root.ShellUtils.mergeAllLines;
import android.content.Context;
import com.topjohnwu.superuser.Shell;
import org.adaway.R;
import org.adaway.db.AppDatabase;
import org.adaway.db.dao.HostEntryDao;
import org.adaway.db.dao.HostsSourceDao;
import org.adaway.db.entity.HostEntry;
import org.adaway.db.entity.HostsSource;
import org.adaway.helper.PreferenceHelper;
import org.adaway.model.adblocking.AdBlockMethod;
import org.adaway.model.adblocking.AdBlockModel;
import org.adaway.model.error.HostErrorException;
import org.adaway.util.AppExecutors;
import org.adaway.util.WebServerUtils;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Executor;
import timber.log.Timber;
/**
* This class is the model to represent hosts file installation.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class RootModel extends AdBlockModel {
private static final String HEADER1 = "# This hosts file has been generated by AdAway on: ";
private static final String HEADER2 = "# Please do not modify it directly, it will be overwritten when AdAway is applied again.";
private static final String HEADER_SOURCES = "# This file is generated from the following sources:";
private final HostsSourceDao hostsSourceDao;
private final HostEntryDao hostEntryDao;
/**
* Constructor.
*
* @param context The application context.
*/
public RootModel(Context context) {
super(context);
// Get DOA
AppDatabase database = AppDatabase.getInstance(this.context);
this.hostsSourceDao = database.hostsSourceDao();
this.hostEntryDao = database.hostEntryDao();
// Check if host list is applied
Executor executor = AppExecutors.getInstance().diskIO();
executor.execute(this::checkApplied);
executor.execute(() -> syncPreferences(context));
}
@Override
public AdBlockMethod getMethod() {
return ROOT;
}
@Override
public void apply() throws HostErrorException {
setState(R.string.status_apply_sources);
setState(R.string.status_create_new_hosts);
createNewHostsFile();
setState(R.string.status_copy_new_hosts);
copyNewHostsFile();
setState(R.string.status_check_copy);
setState(R.string.status_hosts_updated);
this.applied.postValue(true);
}
/**
* Revert to the default hosts file.
*
* @throws HostErrorException If the hosts file could not be reverted.
*/
@Override
public void revert() throws HostErrorException {
// Update status
setState(R.string.status_revert);
try {
// Revert hosts file
revertHostFile();
setState(R.string.status_revert_done);
this.applied.postValue(false);
} catch (IOException exception) {
throw new HostErrorException(REVERT_FAIL, exception);
}
}
@Override
public boolean isRecordingLogs() {
return TcpdumpUtils.isTcpdumpRunning();
}
@Override
public void setRecordingLogs(boolean recording) {
if (recording) {
TcpdumpUtils.startTcpdump(this.context);
} else {
TcpdumpUtils.stopTcpdump();
}
}
@Override
public List<String> getLogs() {
return TcpdumpUtils.getLogs(this.context);
}
@Override
public void clearLogs() {
TcpdumpUtils.clearLogFile(this.context);
}
private void checkApplied() {
boolean applied = false;
Shell.Result result = Shell.cmd("head -n 1 " + ANDROID_SYSTEM_ETC_HOSTS).exec();
if (!result.isSuccess()) {
Timber.e("Failed to read first line of hosts file. Error code: %s", result.getCode());
} else {
applied = mergeAllLines(result.getOut()).startsWith(HEADER1);
}
this.applied.postValue(applied);
}
private void syncPreferences(Context context) {
if (PreferenceHelper.getWebServerEnabled(context) && !WebServerUtils.isWebServerRunning()) {
WebServerUtils.startWebServer(context);
}
}
private void deleteNewHostsFile() {
// delete generated hosts file from private storage
this.context.deleteFile(HOSTS_FILENAME);
}
private void copyNewHostsFile() throws HostErrorException {
try {
copyHostsFile(HOSTS_FILENAME);
} catch (CommandException exception) {
throw new HostErrorException(COPY_FAIL, exception);
}
}
/**
* Create a new hosts files in a private file from downloaded hosts sources.
*
* @throws HostErrorException If the new hosts file could not be created.
*/
private void createNewHostsFile() throws HostErrorException {
deleteNewHostsFile();
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(this.context.openFileOutput(HOSTS_FILENAME, MODE_PRIVATE)))) {
writeHostsHeader(writer);
writeLoopbackToHosts(writer);
writeHosts(writer);
} catch (IOException exception) {
throw new HostErrorException(PRIVATE_FILE_FAILED, exception);
}
}
private void writeHostsHeader(BufferedWriter writer) throws IOException {
// Format current date
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US);
Date now = new Date();
String date = formatter.format(now);
// Write header
writer.write(HEADER1);
writer.write(date);
writer.newLine();
writer.write(HEADER2);
writer.newLine();
// Write hosts source
writer.write(HEADER_SOURCES);
writer.newLine();
for (HostsSource hostsSource : this.hostsSourceDao.getEnabled()) {
writer.write("# - " + hostsSource.getLabel() + ":" + hostsSource.getUrl());
writer.newLine();
}
// Write empty line separator
writer.newLine();
}
private void writeLoopbackToHosts(BufferedWriter writer) throws IOException {
writer.write(LOCALHOST_IPV4 + " " + LOCALHOST_HOSTNAME);
writer.newLine();
writer.write(LOCALHOST_IPV6 + " " + LOCALHOST_HOSTNAME);
writer.newLine();
}
private void writeHosts(BufferedWriter writer) throws IOException {
// Get user preferences
String redirectionIpv4 = PreferenceHelper.getRedirectionIpv4(this.context);
String redirectionIpv6 = PreferenceHelper.getRedirectionIpv6(this.context);
boolean enableIpv6 = PreferenceHelper.getEnableIpv6(this.context);
// Write each hostname
for (HostEntry entry : this.hostEntryDao.getAll()) {
String hostname = entry.getHost();
if (entry.getType() == REDIRECTED) {
writer.write(entry.getRedirection() + " " + hostname);
writer.newLine();
} else {
writer.write(redirectionIpv4 + " " + hostname);
writer.newLine();
if (enableIpv6) {
writer.write(redirectionIpv6 + " " + hostname);
writer.newLine();
}
}
}
}
/**
* Revert to default hosts file.
*
* @throws IOException If the hosts file could not be reverted.
*/
private void revertHostFile() throws IOException {
// Create private file
try (FileOutputStream fos = this.context.openFileOutput(DEFAULT_HOSTS_FILENAME, MODE_PRIVATE)) {
// Write default localhost as hosts file
String localhost = LOCALHOST_IPV4 + " " + LOCALHOST_HOSTNAME + LINE_SEPARATOR +
LOCALHOST_IPV6 + " " + LOCALHOST_HOSTNAME + LINE_SEPARATOR;
fos.write(localhost.getBytes());
// Copy generated hosts file to target location
copyHostsFile(DEFAULT_HOSTS_FILENAME);
// Delete generated hosts file after applying it
this.context.deleteFile(DEFAULT_HOSTS_FILENAME);
} catch (Exception exception) {
throw new IOException("Unable to revert hosts file.", exception);
}
}
/**
* Copy source file from private storage of AdAway to hosts file target using root commands.
*/
private void copyHostsFile(String source) throws HostErrorException, CommandException {
String privateDir = this.context.getFilesDir().getAbsolutePath();
String privateFile = privateDir + File.separator + source;
// if the target has a trailing slash, it is not a valid target!
String target = ANDROID_SYSTEM_ETC_HOSTS;
File targetFile = new File(target);
/* check for space on partition */
long size = new File(privateFile).length();
Timber.i("Size of hosts file: %s.", size);
if (!hasEnoughSpaceOnPartition(targetFile, size)) {
throw new HostErrorException(NOT_ENOUGH_SPACE);
}
/* Execute commands */
boolean writable = isWritable(targetFile);
try {
if (!writable) {
// remount for write access
Timber.i("Remounting for RW…");
if (!ShellUtils.remountPartition(targetFile, READ_WRITE)) {
throw new CommandException("Failed to remount hosts file partition as read-write.");
}
}
// Copy hosts file then set owner and permissions
Shell.Result result = Shell.cmd(
"dd if=" + privateFile + " of=" + target,
COMMAND_CHOWN + " " + target,
COMMAND_CHMOD_644 + " " + target
).exec();
if (!result.isSuccess()) {
throw new CommandException("Failed to copy hosts file: " + mergeAllLines(result.getErr()));
}
} finally {
if (!writable) {
// after all remount target back as read only
ShellUtils.remountPartition(targetFile, READ_ONLY);
}
}
}
/**
* Check if there is enough space on partition where target is located
*
* @param size size of file to put on partition
* @param target path where to put the file
* @return true if it will fit on partition of target, false if it will not fit.
*/
private boolean hasEnoughSpaceOnPartition(File target, long size) {
long freeSpace = target.getFreeSpace();
return (freeSpace == 0 || freeSpace > size);
}
}

View file

@ -0,0 +1,99 @@
package org.adaway.model.root;
import static com.topjohnwu.superuser.ShellUtils.escapedString;
import android.content.Context;
import com.topjohnwu.superuser.Shell;
import java.io.File;
import java.util.List;
import java.util.Optional;
import timber.log.Timber;
/**
* This class is an utility class to help with shell commands.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class ShellUtils {
private static final String EXECUTABLE_PREFIX = "lib";
private static final String EXECUTABLE_SUFFIX = "_exec.so";
/**
* Private constructor.
*/
private ShellUtils() {
}
public static String mergeAllLines(List<String> lines) {
return String.join("\n", lines);
}
public static boolean isBundledExecutableRunning(String executable) {
return Shell.cmd("ps -A | grep " + EXECUTABLE_PREFIX + executable + EXECUTABLE_SUFFIX).exec().isSuccess();
}
public static boolean runBundledExecutable(Context context, String executable, String parameters) {
String nativeLibraryDir = context.getApplicationInfo().nativeLibraryDir;
String command = "LD_LIBRARY_PATH=" + nativeLibraryDir + " " +
nativeLibraryDir + File.separator + EXECUTABLE_PREFIX + executable + EXECUTABLE_SUFFIX + " " +
parameters + " &";
return Shell.cmd(command).exec().isSuccess();
}
public static void killBundledExecutable(String executable) {
Shell.cmd("killall " + EXECUTABLE_PREFIX + executable + EXECUTABLE_SUFFIX).exec();
}
/**
* Check if a path is writable.
*
* @param file The file to check.
* @return <code>true</code> if the path is writable, <code>false</code> otherwise.
*/
public static boolean isWritable(File file) {
// Check first if file can be written without privileges
if (file.canWrite()) {
return true;
}
return Shell.cmd("test -w " + escapedString(file.getAbsolutePath()))
.exec()
.isSuccess();
}
public static boolean remountPartition(File file, MountType type) {
Optional<String> partitionOptional = findPartition(file);
if (!partitionOptional.isPresent()) {
return false;
}
String partition = partitionOptional.get();
Shell.Result result = Shell.cmd("mount -o " + type.getOption() + ",remount " + partition).exec();
boolean success = result.isSuccess();
if (!success) {
Timber.w("Failed to remount partition %s as %s: %s.", partition, type.getOption(), mergeAllLines(result.getErr()));
}
return success;
}
private static Optional<String> findPartition(File file) {
// Get mount points
Shell.Result result = Shell.cmd("cat /proc/mounts | cut -d ' ' -f2").exec();
List<String> out = result.getOut();
// Check file and each parent against mount points
while (file != null) {
String path = file.getAbsolutePath();
for (String mount : out) {
if (path.equals(mount)) {
return Optional.of(mount);
}
}
file = file.getParentFile();
}
return Optional.empty();
}
}

View file

@ -0,0 +1,202 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.model.root;
import android.content.Context;
import com.topjohnwu.superuser.Shell;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.util.Collections.emptyList;
import static org.adaway.model.root.ShellUtils.isBundledExecutableRunning;
import static org.adaway.model.root.ShellUtils.killBundledExecutable;
import static org.adaway.model.root.ShellUtils.mergeAllLines;
import static org.adaway.model.root.ShellUtils.runBundledExecutable;
import timber.log.Timber;
class TcpdumpUtils {
private static final String TCPDUMP_EXECUTABLE = "tcpdump";
private static final String TCPDUMP_LOG = "dns_log.txt";
private static final String TCPDUMP_HOSTNAME_REGEX = "(?:A\\?|AAAA\\?)\\s(\\S+)\\.\\s";
private static final Pattern TCPDUMP_HOSTNAME_PATTERN = Pattern.compile(TCPDUMP_HOSTNAME_REGEX);
/**
* Private constructor.
*/
private TcpdumpUtils() {
}
/**
* Checks if tcpdump is running
*
* @return true if tcpdump is running
*/
static boolean isTcpdumpRunning() {
return isBundledExecutableRunning(TCPDUMP_EXECUTABLE);
}
/**
* Start tcpdump tool.
*
* @param context The application context.
* @return returns true if starting worked
*/
static boolean startTcpdump(Context context) {
Timber.d("Starting tcpdump...");
checkSystemTcpdump();
File file = getLogFile(context);
try {
// Create log file before using it with tcpdump if not exists
if (!file.exists() && !file.createNewFile()) {
return false;
}
} catch (IOException e) {
Timber.e(e, "Problem while getting cache directory!");
return false;
}
// "-i any": listen on any network interface
// "-p": disable promiscuous mode (doesn't work anyway)
// "-l": Make stdout line buffered. Useful if you want to see the data while
// capturing it.
// "-v": verbose
// "-t": don't print a timestamp
// "-s 0": capture first 512 bit of packet to get DNS content
String parameters = "-i any -p -l -v -t -s 512 'udp dst port 53' >> " + file + " 2>&1";
return runBundledExecutable(context, TCPDUMP_EXECUTABLE, parameters);
}
/**
* Stop tcpdump.
*/
static void stopTcpdump() {
killBundledExecutable(TCPDUMP_EXECUTABLE);
}
/**
* Check if tcpdump binary in bundled in the system.
*/
static void checkSystemTcpdump() {
try {
Shell.Result result = Shell.cmd("tcpdump --version").exec();
int exitCode = result.getCode();
String output = mergeAllLines(result.getOut());
String msg = "Tcpdump " + (
exitCode == 0 ?
"present" :
"missing (" + exitCode + ")"
) + "\n" + output;
Timber.i(msg);
} catch (Exception exception) {
Timber.w(exception, "Failed to check system tcpdump binary.");
}
}
/**
* Get the tcpdump log file.
*
* @param context The application context.
* @return The tcpdump log file.
*/
static File getLogFile(Context context) {
return new File(context.getCacheDir(), TCPDUMP_LOG);
}
/**
* Get the tcpdump log content.
*
* @param context The application context.
* @return The tcpdump log file content.
*/
static List<String> getLogs(Context context) {
Path logPath = getLogFile(context).toPath();
// Check if the log file exists
if (!Files.exists(logPath)) {
return emptyList();
}
try (Stream<String> lines = Files.lines(logPath)) {
return lines
.map(TcpdumpUtils::getTcpdumpHostname)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
} catch (IOException exception) {
Timber.e(exception, "Can not get cache directory.");
return emptyList();
}
}
/**
* Delete log file of tcpdump.
*
* @param context The application context.
*/
static boolean clearLogFile(Context context) {
// Get the log file
File file = getLogFile(context);
// Check if file exists
if (!file.exists()) {
return true;
}
// Truncate the file content
try (FileOutputStream outputStream = new FileOutputStream(file, false)) {
// Only truncate the file
outputStream.close(); // Useless but help lint
} catch (IOException exception) {
Timber.e(exception, "Error while truncating the tcpdump file!");
// Return failed to clear the log file
return false;
}
// Return successfully clear the log file
return true;
}
/**
* Gets hostname out of tcpdump log line.
*
* @param input One line from dns log.
* @return A hostname or {code null} if no DNS query in the input.
*/
private static String getTcpdumpHostname(String input) {
Matcher tcpdumpHostnameMatcher = TCPDUMP_HOSTNAME_PATTERN.matcher(input);
if (tcpdumpHostnameMatcher.find()) {
return tcpdumpHostnameMatcher.group(1);
} else {
Timber.d("Does not find: %s.", input);
return null;
}
}
}

View file

@ -0,0 +1,9 @@
/**
* The root ad-blocker related implementations.
*
* It relies on libsu from John Wu and shell commands.
* Supported shell commands are described here: https://chromium.googlesource.com/aosp/platform/system/core/+/upstream/shell_and_utilities/#android-8_0-oreo
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
package org.adaway.model.root;

View file

@ -0,0 +1,267 @@
package org.adaway.model.source;
import static org.adaway.db.entity.ListType.ALLOWED;
import static org.adaway.db.entity.ListType.BLOCKED;
import static org.adaway.db.entity.ListType.REDIRECTED;
import static org.adaway.util.Constants.BOGUS_IPV4;
import static org.adaway.util.Constants.LOCALHOST_HOSTNAME;
import static org.adaway.util.Constants.LOCALHOST_IPV4;
import static org.adaway.util.Constants.LOCALHOST_IPV6;
import org.adaway.db.dao.HostListItemDao;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.HostsSource;
import org.adaway.db.entity.ListType;
import org.adaway.util.RegexUtils;
import java.io.BufferedReader;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import timber.log.Timber;
/**
* This class is an {@link HostsSource} loader.<br>
* It parses a source and loads it to database.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
class SourceLoader {
private static final String TAG = "SourceLoader";
private static final String END_OF_QUEUE_MARKER = "#EndOfQueueMarker";
private static final int INSERT_BATCH_SIZE = 100;
private static final String HOSTS_PARSER = "^\\s*([^#\\s]+)\\s+([^#\\s]+).*$";
static final Pattern HOSTS_PARSER_PATTERN = Pattern.compile(HOSTS_PARSER);
private final HostsSource source;
SourceLoader(HostsSource hostsSource) {
this.source = hostsSource;
}
void parse(BufferedReader reader, HostListItemDao hostListItemDao) {
// Clear current hosts
hostListItemDao.clearSourceHosts(this.source.getId());
// Create batch
int parserCount = 3;
LinkedBlockingQueue<String> hostsLineQueue = new LinkedBlockingQueue<>();
LinkedBlockingQueue<HostListItem> hostsListItemQueue = new LinkedBlockingQueue<>();
SourceReader sourceReader = new SourceReader(reader, hostsLineQueue, parserCount);
ItemInserter inserter = new ItemInserter(hostsListItemQueue, hostListItemDao, parserCount);
ExecutorService executorService = Executors.newFixedThreadPool(
parserCount + 2,
r -> new Thread(r, TAG)
);
executorService.execute(sourceReader);
for (int i = 0; i < parserCount; i++) {
executorService.execute(new HostListItemParser(this.source, hostsLineQueue, hostsListItemQueue));
}
Future<Integer> inserterFuture = executorService.submit(inserter);
try {
Integer inserted = inserterFuture.get();
Timber.i("%s host list items inserted.", inserted);
} catch (ExecutionException e) {
Timber.w(e, "Failed to parse hosts sources.");
} catch (InterruptedException e) {
Timber.w(e, "Interrupted while parsing sources.");
Thread.currentThread().interrupt();
}
executorService.shutdown();
}
private static class SourceReader implements Runnable {
private final BufferedReader reader;
private final BlockingQueue<String> queue;
private final int parserCount;
private SourceReader(BufferedReader reader, BlockingQueue<String> queue, int parserCount) {
this.reader = reader;
this.queue = queue;
this.parserCount = parserCount;
}
@Override
public void run() {
try {
this.reader.lines().forEach(this.queue::add);
} catch (Throwable t) {
Timber.w(t, "Failed to read hosts source.");
} finally {
// Send end of queue marker to parsers
for (int i = 0; i < this.parserCount; i++) {
this.queue.add(END_OF_QUEUE_MARKER);
}
}
}
}
private static class HostListItemParser implements Runnable {
private final HostsSource source;
private final BlockingQueue<String> lineQueue;
private final BlockingQueue<HostListItem> itemQueue;
private HostListItemParser(HostsSource source, BlockingQueue<String> lineQueue, BlockingQueue<HostListItem> itemQueue) {
this.source = source;
this.lineQueue = lineQueue;
this.itemQueue = itemQueue;
}
@Override
public void run() {
boolean allowedList = this.source.isAllowEnabled();
boolean endOfSource = false;
while (!endOfSource) {
try {
String line = this.lineQueue.take();
// Check end of queue marker
//noinspection StringEquality
if (line == END_OF_QUEUE_MARKER) {
endOfSource = true;
// Send end of queue marker to inserter
HostListItem endItem = new HostListItem();
endItem.setHost(line);
this.itemQueue.add(endItem);
} // Check comments
else if (line.isEmpty() || line.charAt(0) == '#') {
Timber.d("Skip comment: %s.", line);
} else {
HostListItem item = allowedList ? parseAllowListItem(line) : parseHostListItem(line);
if (item != null && isRedirectionValid(item) && isHostValid(item)) {
this.itemQueue.add(item);
}
}
} catch (InterruptedException e) {
Timber.w(e, "Interrupted while parsing hosts list item.");
endOfSource = true;
Thread.currentThread().interrupt();
}
}
}
private HostListItem parseHostListItem(String line) {
Matcher matcher = HOSTS_PARSER_PATTERN.matcher(line);
if (!matcher.matches()) {
Timber.d("Does not match: %s.", line);
return null;
}
// Check IP address validity or while list entry (if allowed)
String ip = matcher.group(1);
String hostname = matcher.group(2);
assert hostname != null;
// Skip localhost name
if (LOCALHOST_HOSTNAME.equals(hostname)) {
return null;
}
// check if ip is 127.0.0.1 or 0.0.0.0
ListType type;
if (LOCALHOST_IPV4.equals(ip)
|| BOGUS_IPV4.equals(ip)
|| LOCALHOST_IPV6.equals(ip)) {
type = BLOCKED;
} else if (this.source.isRedirectEnabled()) {
type = REDIRECTED;
} else {
return null;
}
HostListItem item = new HostListItem();
item.setType(type);
item.setHost(hostname);
item.setEnabled(true);
if (type == REDIRECTED) {
item.setRedirection(ip);
}
item.setSourceId(this.source.getId());
return item;
}
private HostListItem parseAllowListItem(String line) {
// Extract hostname
int indexOf = line.indexOf('#');
if (indexOf == 1) {
line = line.substring(0, indexOf);
}
line = line.trim();
// Create item
HostListItem item = new HostListItem();
item.setType(ALLOWED);
item.setHost(line);
item.setEnabled(true);
item.setSourceId(this.source.getId());
return item;
}
private boolean isRedirectionValid(HostListItem item) {
return item.getType() != REDIRECTED || RegexUtils.isValidIP(item.getRedirection());
}
private boolean isHostValid(HostListItem item) {
String hostname = item.getHost();
if (item.getType() == BLOCKED) {
if (hostname.indexOf('?') != -1 || hostname.indexOf('*') != -1) {
return false;
}
return RegexUtils.isValidHostname(hostname);
}
return RegexUtils.isValidWildcardHostname(hostname);
}
}
private static class ItemInserter implements Callable<Integer> {
private final BlockingQueue<HostListItem> hostListItemQueue;
private final HostListItemDao hostListItemDao;
private final int parserCount;
private ItemInserter(BlockingQueue<HostListItem> itemQueue, HostListItemDao hostListItemDao, int parserCount) {
this.hostListItemQueue = itemQueue;
this.hostListItemDao = hostListItemDao;
this.parserCount = parserCount;
}
@Override
public Integer call() {
int inserted = 0;
int workerStopped = 0;
HostListItem[] batch = new HostListItem[INSERT_BATCH_SIZE];
int cacheSize = 0;
boolean queueEmptied = false;
while (!queueEmptied) {
try {
HostListItem item = this.hostListItemQueue.take();
// Check end of queue marker
//noinspection StringEquality
if (item.getHost() == END_OF_QUEUE_MARKER) {
workerStopped++;
if (workerStopped >= this.parserCount) {
queueEmptied = true;
}
} else {
batch[cacheSize++] = item;
if (cacheSize >= batch.length) {
this.hostListItemDao.insert(batch);
inserted += cacheSize;
cacheSize = 0;
}
}
} catch (InterruptedException e) {
Timber.w(e, "Interrupted while inserted hosts list item.");
queueEmptied = true;
Thread.currentThread().interrupt();
}
}
// Flush current batch
HostListItem[] remaining = new HostListItem[cacheSize];
System.arraycopy(batch, 0, remaining, 0, remaining.length);
this.hostListItemDao.insert(remaining);
inserted += cacheSize;
// Return number of inserted items
return inserted;
}
}
}

View file

@ -0,0 +1,508 @@
package org.adaway.model.source;
import static android.content.Context.CONNECTIVITY_SERVICE;
import static android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED;
import static org.adaway.model.error.HostError.DOWNLOAD_FAILED;
import static org.adaway.model.error.HostError.NO_CONNECTION;
import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED;
import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
import static java.time.format.FormatStyle.MEDIUM;
import static java.time.temporal.ChronoUnit.WEEKS;
import static java.util.Objects.requireNonNull;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.adaway.R;
import org.adaway.db.AppDatabase;
import org.adaway.db.converter.ZonedDateTimeConverter;
import org.adaway.db.dao.HostEntryDao;
import org.adaway.db.dao.HostListItemDao;
import org.adaway.db.dao.HostsSourceDao;
import org.adaway.db.entity.HostEntry;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.HostsSource;
import org.adaway.model.error.HostErrorException;
import org.adaway.model.git.GitHostsSource;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.MalformedURLException;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
import okhttp3.Cache;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import timber.log.Timber;
/**
* This class is the model to represent hosts source management.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class SourceModel {
/**
* The HTTP client cache size (100Mo).
*/
private static final long CACHE_SIZE = 100L * 1024L * 1024L;
private static final String LAST_MODIFIED_HEADER = "Last-Modified";
private static final String IF_NONE_MATCH_HEADER = "If-None-Match";
private static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since";
private static final String ENTITY_TAG_HEADER = "ETag";
private static final String WEAK_ENTITY_TAG_PREFIX = "W/";
/**
* The application context.
*/
private final Context context;
/**
* The {@link HostsSource} DAO.
*/
private final HostsSourceDao hostsSourceDao;
/**
* The {@link HostListItem} DAO.
*/
private final HostListItemDao hostListItemDao;
/**
* The {@link HostEntry} DAO.
*/
private final HostEntryDao hostEntryDao;
/**
* The update available status.
*/
private final MutableLiveData<Boolean> updateAvailable;
/**
* The model state.
*/
private final MutableLiveData<String> state;
/**
* The HTTP client to download hosts sources ({@code null} until initialized by {@link #getHttpClient()}).
*/
private OkHttpClient cachedHttpClient;
/**
* Constructor.
*
* @param context The application context.
*/
public SourceModel(Context context) {
this.context = context;
AppDatabase database = AppDatabase.getInstance(this.context);
this.hostsSourceDao = database.hostsSourceDao();
this.hostListItemDao = database.hostsListItemDao();
this.hostEntryDao = database.hostEntryDao();
this.state = new MutableLiveData<>("");
this.updateAvailable = new MutableLiveData<>();
this.updateAvailable.setValue(false);
SourceUpdateService.syncPreferences(context);
}
/**
* Get the model state.
*
* @return The model state.
*/
public LiveData<String> getState() {
return this.state;
}
/**
* Get the update available status.
*
* @return {@code true} if source update is available, {@code false} otherwise.
*/
public LiveData<Boolean> isUpdateAvailable() {
return this.updateAvailable;
}
/**
* Check if there is update available for hosts sources.
*
* @throws HostErrorException If the hosts sources could not be checked.
*/
public boolean checkForUpdate() throws HostErrorException {
// Check current connection
if (isDeviceOffline()) {
throw new HostErrorException(NO_CONNECTION);
}
// Initialize update status
boolean updateAvailable = false;
// Get enabled hosts sources
List<HostsSource> sources = this.hostsSourceDao.getEnabled();
if (sources.isEmpty()) {
// Return no update as no source
this.updateAvailable.postValue(false);
return false;
}
// Update state
setState(R.string.status_check);
// Check each source
for (HostsSource source : sources) {
// Get URL and lastModified from db
ZonedDateTime lastModifiedLocal = source.getLocalModificationDate();
// Update state
setState(R.string.status_check_source, source.getLabel());
// Get hosts source last update
ZonedDateTime lastModifiedOnline = getHostsSourceLastUpdate(source);
// Some help with debug here
Timber.d("lastModifiedLocal: %s", dateToString(lastModifiedLocal));
Timber.d("lastModifiedOnline: %s", dateToString(lastModifiedOnline));
// Save last modified online
this.hostsSourceDao.updateOnlineModificationDate(source.getId(), lastModifiedOnline);
// Check if last modified online retrieved
if (lastModifiedOnline == null) {
// If not, consider update is available if install is older than a week
ZonedDateTime lastWeek = ZonedDateTime.now().minus(1, WEEKS);
if (lastModifiedLocal != null && lastModifiedLocal.isBefore(lastWeek)) {
updateAvailable = true;
}
} else {
// Check if source was never installed or installed before the last update
if (lastModifiedLocal == null || lastModifiedOnline.isAfter(lastModifiedLocal)) {
updateAvailable = true;
}
}
}
// Update statuses
Timber.d("Update check result: %s.", updateAvailable);
if (updateAvailable) {
setState(R.string.status_update_available);
} else {
setState(R.string.status_no_update_found);
}
this.updateAvailable.postValue(updateAvailable);
return updateAvailable;
}
/**
* Format {@link ZonedDateTime} for printing.
*
* @param zonedDateTime The date to format.
* @return The formatted date string.
*/
private String dateToString(ZonedDateTime zonedDateTime) {
if (zonedDateTime == null) {
return "not defined";
} else {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(MEDIUM);
return zonedDateTime + " (" + zonedDateTime.format(dateTimeFormatter) + ")";
}
}
/**
* Checks if device is offline.
*
* @return returns {@code true} if device is offline, {@code false} otherwise.
*/
private boolean isDeviceOffline() {
ConnectivityManager connectivityManager = (ConnectivityManager) this.context.getSystemService(CONNECTIVITY_SERVICE);
if (connectivityManager == null) {
return false;
}
NetworkInfo netInfo = connectivityManager.getActiveNetworkInfo();
return netInfo == null || !netInfo.isConnectedOrConnecting();
}
/**
* Get the hosts source last online update.
*
* @param source The hosts source to get last online update.
* @return The last online date, {@code null} if the date could not be retrieved.
*/
@Nullable
private ZonedDateTime getHostsSourceLastUpdate(HostsSource source) {
switch (source.getType()) {
case URL:
return getUrlLastUpdate(source);
case FILE:
Uri fileUri = Uri.parse(source.getUrl());
return getFileLastUpdate(fileUri);
default:
return null;
}
}
/**
* Get the url last online update.
*
* @param source The source to get last online update.
* @return The last online date, {@code null} if the date could not be retrieved.
*/
private ZonedDateTime getUrlLastUpdate(HostsSource source) {
String url = source.getUrl();
Timber.v("Checking url last update for source: %s.", url);
// Check Git hosting
if (GitHostsSource.isHostedOnGit(url)) {
try {
return GitHostsSource.getSource(url).getLastUpdate();
} catch (MalformedURLException e) {
Timber.w(e, "Failed to get Git last commit for url %s.", url);
return null;
}
}
// Default hosting
Request request = getRequestFor(source).head().build();
try (Response response = getHttpClient().newCall(request).execute()) {
String lastModified = response.header(LAST_MODIFIED_HEADER);
if (lastModified == null) {
return response.code() == HTTP_NOT_MODIFIED ?
source.getOnlineModificationDate() : null;
}
return ZonedDateTime.parse(lastModified, RFC_1123_DATE_TIME);
} catch (IOException | DateTimeParseException e) {
Timber.e(e, "Exception while fetching last modified date of source %s.", url);
return null;
}
}
/**
* Get the file last modified date.
*
* @param fileUri The file uri to get last modified date.
* @return The file last modified date, {@code null} if date could not be retrieved.
*/
private ZonedDateTime getFileLastUpdate(Uri fileUri) {
ContentResolver contentResolver = this.context.getContentResolver();
try (Cursor cursor = contentResolver.query(fileUri, null, null, null, null)) {
if (cursor == null || !cursor.moveToFirst()) {
Timber.w("The content resolver could not find %s.", fileUri);
return null;
}
int columnIndex = cursor.getColumnIndex(COLUMN_LAST_MODIFIED);
if (columnIndex == -1) {
Timber.w("The content resolver does not support last modified column %s.", fileUri);
return null;
}
return ZonedDateTimeConverter.fromTimestamp(cursor.getLong(columnIndex));
} catch (SecurityException e) {
Timber.i(e, "The SAF permission was removed.");
return null;
}
}
/**
* Retrieve all hosts sources files to copy into a private local file.
*
* @throws HostErrorException If the hosts sources could not be downloaded.
*/
public void retrieveHostsSources() throws HostErrorException {
// Check connection status
if (isDeviceOffline()) {
throw new HostErrorException(NO_CONNECTION);
}
// Update state to downloading
setState(R.string.status_retrieve);
// Initialize copy counters
int numberOfCopies = 0;
int numberOfFailedCopies = 0;
// Compute current date in UTC timezone
ZonedDateTime now = ZonedDateTime.now();
// Get each hosts source
for (HostsSource source : this.hostsSourceDao.getAll()) {
int sourceId = source.getId();
// Clear disabled source
if (!source.isEnabled()) {
this.hostListItemDao.clearSourceHosts(sourceId);
this.hostsSourceDao.clearProperties(sourceId);
continue;
}
// Get hosts source last update
ZonedDateTime onlineModificationDate = getHostsSourceLastUpdate(source);
if (onlineModificationDate == null) {
onlineModificationDate = now;
}
// Check if update available
ZonedDateTime localModificationDate = source.getLocalModificationDate();
if (localModificationDate != null && localModificationDate.isAfter(onlineModificationDate)) {
Timber.i("Skip source %s: no update.", source.getLabel());
continue;
}
// Increment number of copy
numberOfCopies++;
try {
// Check hosts source type
switch (source.getType()) {
case URL:
downloadHostSource(source);
break;
case FILE:
readSourceFile(source);
break;
default:
Timber.w("Hosts source type is not supported.");
}
// Update local and online modification dates to now
localModificationDate = onlineModificationDate.isAfter(now) ? onlineModificationDate : now;
this.hostsSourceDao.updateModificationDates(sourceId, localModificationDate, onlineModificationDate);
// Update size
this.hostsSourceDao.updateSize(sourceId);
} catch (IOException e) {
Timber.w(e, "Failed to retrieve host source %s.", source.getUrl());
// Increment number of failed copy
numberOfFailedCopies++;
}
}
// Check if all copies failed
if (numberOfCopies == numberOfFailedCopies && numberOfCopies != 0) {
throw new HostErrorException(DOWNLOAD_FAILED);
}
// Synchronize hosts entries
syncHostEntries();
// Mark no update available
this.updateAvailable.postValue(false);
}
/**
* Synchronize hosts entries from current source states.
*/
public void syncHostEntries() {
setState(R.string.status_sync_database);
this.hostEntryDao.sync();
}
/**
* Get the HTTP client to download hosts sources.
*
* @return The HTTP client to download hosts sources.
*/
@NonNull
private OkHttpClient getHttpClient() {
if (this.cachedHttpClient == null) {
this.cachedHttpClient = new OkHttpClient.Builder()
.cache(new Cache(this.context.getCacheDir(), CACHE_SIZE))
.build();
}
return this.cachedHttpClient;
}
/**
* Get request builder for an hosts source.
* All cache data available are filled into the headers.
*
* @param source The hosts source to get request builder.
* @return The hosts source request builder.
*/
private Request.Builder getRequestFor(HostsSource source) {
Request.Builder request = new Request.Builder().url(source.getUrl());
if (source.getEntityTag() != null) {
request = request.header(IF_NONE_MATCH_HEADER, source.getEntityTag());
}
if (source.getOnlineModificationDate() != null) {
String lastModified = source.getOnlineModificationDate().format(RFC_1123_DATE_TIME);
request = request.header(IF_MODIFIED_SINCE_HEADER, lastModified);
}
return request;
}
/**
* Download an hosts source file and append it to the database.
*
* @param source The hosts source to download.
* @throws IOException If the hosts source could not be downloaded.
*/
private void downloadHostSource(HostsSource source) throws IOException {
// Get hosts file URL
String hostsFileUrl = source.getUrl();
Timber.v("Downloading hosts file: %s.", hostsFileUrl);
// Set state to downloading hosts source
setState(R.string.status_download_source, hostsFileUrl);
// Create request
Request request = getRequestFor(source).build();
// Request hosts file and open byte stream
try (Response response = getHttpClient().newCall(request).execute();
Reader reader = requireNonNull(response.body()).charStream();
BufferedReader bufferedReader = new BufferedReader(reader)) {
// Skip source parsing if not modified
if (response.code() == HTTP_NOT_MODIFIED) {
Timber.d("Source %s was not updated since last fetch.", source.getUrl());
return;
}
// Extract ETag if present
String entityTag = response.header(ENTITY_TAG_HEADER);
if (entityTag != null) {
if (entityTag.startsWith(WEAK_ENTITY_TAG_PREFIX)) {
entityTag = entityTag.substring(WEAK_ENTITY_TAG_PREFIX.length());
}
this.hostsSourceDao.updateEntityTag(source.getId(), entityTag);
}
// Parse source
parseSourceInputStream(source, bufferedReader);
} catch (IOException e) {
throw new IOException("Exception while downloading hosts file from " + hostsFileUrl + ".", e);
}
}
/**
* Read a hosts source file and append it to the database.
*
* @param hostsSource The hosts source to copy.
* @throws IOException If the hosts source could not be copied.
*/
private void readSourceFile(HostsSource hostsSource) throws IOException {
// Get hosts file URI
String hostsFileUrl = hostsSource.getUrl();
Uri fileUri = Uri.parse(hostsFileUrl);
Timber.v("Reading hosts source file: %s.", hostsFileUrl);
// Set state to copying hosts source
setState(R.string.status_read_source, hostsFileUrl);
try (InputStream inputStream = this.context.getContentResolver().openInputStream(fileUri);
InputStreamReader reader = new InputStreamReader(inputStream);
BufferedReader bufferedReader = new BufferedReader(reader)) {
parseSourceInputStream(hostsSource, bufferedReader);
} catch (IOException e) {
throw new IOException("Error while reading hosts file from " + hostsFileUrl + ".", e);
}
}
/**
* Parse a source from its input stream to store it into database.
*
* @param hostsSource The host source to parse.
* @param reader The host source reader.
*/
private void parseSourceInputStream(HostsSource hostsSource, BufferedReader reader) {
setState(R.string.status_parse_source, hostsSource.getLabel());
long startTime = System.currentTimeMillis();
new SourceLoader(hostsSource).parse(reader, this.hostListItemDao);
long endTime = System.currentTimeMillis();
Timber.i("Parsed " + hostsSource.getUrl() + " in " + (endTime - startTime) / 1000 + "s");
}
/**
* Enable all hosts sources.
*
* @return {@code true} if at least one source was updated, {@code false} otherwise.
*/
public boolean enableAllSources() {
boolean updated = false;
for (HostsSource source : this.hostsSourceDao.getAll()) {
if (!source.isEnabled()) {
this.hostsSourceDao.toggleEnabled(source);
updated = true;
}
}
return updated;
}
private void setState(@StringRes int stateResId, Object... details) {
String state = this.context.getString(stateResId, details);
Timber.d("Source model state: %s.", state);
this.state.postValue(state);
}
}

View file

@ -0,0 +1,176 @@
package org.adaway.model.source;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.work.Constraints;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import org.adaway.AdAwayApplication;
import org.adaway.helper.NotificationHelper;
import org.adaway.helper.PreferenceHelper;
import org.adaway.model.adblocking.AdBlockModel;
import org.adaway.model.error.HostErrorException;
import static androidx.work.ExistingPeriodicWorkPolicy.KEEP;
import static androidx.work.ExistingPeriodicWorkPolicy.UPDATE;
import static androidx.work.ListenableWorker.Result.failure;
import static androidx.work.ListenableWorker.Result.retry;
import static androidx.work.ListenableWorker.Result.success;
import static java.util.concurrent.TimeUnit.HOURS;
import timber.log.Timber;
/**
* This class is a service to check for hosts sources update.<br/>
* It could be enabled or disabled for periodic check.<br>
* The implementation is based on WorkManager from Android X.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class SourceUpdateService {
/**
* The name of the periodic work.
*/
private static final String WORK_NAME = "HostsUpdateWork";
/**
* Private constructor.
*/
private SourceUpdateService() {
}
/**
* Enable update service.
*
* @param context The application context.
* @param unmeteredNetworkOnly <code>true</code> if the update should be done on unmetered network only, <code>false</code> otherwise.
*/
public static void enable(Context context, boolean unmeteredNetworkOnly) {
enqueueWork(context, UPDATE, unmeteredNetworkOnly);
}
/**
* Disable update service.
*
* @param context The application context.
*/
public static void disable(Context context) {
// Cancel previous work
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME);
}
/**
* Sync service on user preferences.
*
* @param context The application context.
*/
static void syncPreferences(Context context) {
if (PreferenceHelper.getUpdateCheckHostsDaily(context)) {
enqueueWork(context, KEEP, PreferenceHelper.getUpdateOnlyOnWifi(context));
} else {
disable(context);
}
}
private static void enqueueWork(Context context, ExistingPeriodicWorkPolicy workPolicy, boolean unmeteredNetworkOnly) {
// Create work request
PeriodicWorkRequest workRequest = getWorkRequest(unmeteredNetworkOnly);
// Enqueue work request
WorkManager workManager = WorkManager.getInstance(context);
workManager.enqueueUniquePeriodicWork(WORK_NAME, workPolicy, workRequest);
}
/**
* Create source update work request.
*
* @param unmeteredNetworkOnly <code>true</code> if the update should be done on unmetered network only, <code>false</code> otherwise.
* @return The source update work request to queue.
*/
private static PeriodicWorkRequest getWorkRequest(boolean unmeteredNetworkOnly) {
// Create worker constraints
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(unmeteredNetworkOnly ? NetworkType.UNMETERED : NetworkType.CONNECTED)
.setRequiresStorageNotLow(true)
.build();
// Create work request
return new PeriodicWorkRequest.Builder(HostsSourcesUpdateWorker.class, 6, HOURS)
.setConstraints(constraints)
.setInitialDelay(3, HOURS)
.build();
}
/**
* This class is a {@link Worker} to fetch hosts sources updates and install them if needed.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public static class HostsSourcesUpdateWorker extends Worker {
/**
* Constructor.
*
* @param context The application context.
* @param workerParams The parameters to setup this worker.
*/
public HostsSourcesUpdateWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
Timber.i("Starting update worker");
// Create model
AdAwayApplication application = (AdAwayApplication) getApplicationContext();
SourceModel model = application.getSourceModel();
// Check for update
boolean hasUpdate;
try {
hasUpdate = model.checkForUpdate();
} catch (HostErrorException exception) {
// An error occurred, check will be retried
Timber.e(exception, "Failed to check for update. Will retry later.");
return retry();
}
if (hasUpdate) {
// Do update
try {
doUpdate(application);
} catch (HostErrorException exception) {
// Installation failed. Worker failed.
Timber.e(exception, "Failed to apply hosts file during background update.");
return failure();
}
}
// Return as success
return success();
}
/**
* Handle update according user preferences.
*
* @param application The application.
* @throws HostErrorException If the update could not be handled.
*/
private void doUpdate(AdAwayApplication application) throws HostErrorException {
// Check if automatic update are enabled
if (PreferenceHelper.getAutomaticUpdateDaily(application)) {
// Retrieve source updates
SourceModel sourceModel = application.getSourceModel();
sourceModel.retrieveHostsSources();
// Apply source updates
AdBlockModel adBlockModel = application.getAdBlockModel();
adBlockModel.apply();
} else {
// Display update notification
NotificationHelper.showUpdateHostsNotification(application);
}
}
}
}

View file

@ -0,0 +1,48 @@
package org.adaway.model.update;
import android.app.DownloadManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import static android.content.Intent.ACTION_INSTALL_PACKAGE;
import timber.log.Timber;
/**
* This class is a {@link BroadcastReceiver} to install downloaded application updates.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class ApkDownloadReceiver extends BroadcastReceiver {
private final long downloadId;
public ApkDownloadReceiver(long downloadId) {
this.downloadId = downloadId;
}
@Override
public void onReceive(Context context, Intent intent) {
//Fetching the download id received with the broadcast
long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
//Checking if the received broadcast is for our enqueued download by matching download id
if (this.downloadId == id) {
DownloadManager downloadManager = context.getSystemService(DownloadManager.class);
Uri apkUri = downloadManager.getUriForDownloadedFile(id);
if (apkUri == null) {
Timber.w("Failed to download id: %s.", id);
} else {
installApk(context, apkUri);
}
}
}
private void installApk(Context context, Uri apkUri) {
Intent install = new Intent(ACTION_INSTALL_PACKAGE);
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
install.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
install.setData(apkUri);
context.startActivity(install);
}
}

View file

@ -0,0 +1,112 @@
package org.adaway.model.update;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import org.adaway.AdAwayApplication;
import org.adaway.helper.NotificationHelper;
import org.adaway.helper.PreferenceHelper;
import static androidx.work.ExistingPeriodicWorkPolicy.KEEP;
import static androidx.work.ExistingPeriodicWorkPolicy.UPDATE;
import static androidx.work.ListenableWorker.Result.success;
import static java.util.concurrent.TimeUnit.DAYS;
import timber.log.Timber;
/**
* This class is a service to check for application update.<br/>
* It could be enabled or disabled for periodic check.<br>
* The implementation is based on WorkManager from Android X.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class ApkUpdateService {
/**
* The name of the periodic work.
*/
private static final String WORK_NAME = "ApkUpdateWork";
/**
* Private constructor.
*/
private ApkUpdateService() {
}
/**
* Enable update service.
*
* @param context The application context.
*/
public static void enable(Context context) {
enqueueWork(context, UPDATE);
}
/**
* Disable update service.
*
* @param context The application context.
*/
public static void disable(Context context) {
// Cancel previous work
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME);
}
static void syncPreferences(Context context) {
if (PreferenceHelper.getUpdateCheckAppDaily(context)) {
enqueueWork(context, KEEP);
} else {
disable(context);
}
}
private static void enqueueWork(Context context, ExistingPeriodicWorkPolicy workPolicy) {
// Create work request
PeriodicWorkRequest workRequest = new PeriodicWorkRequest.Builder(ApkUpdateWorker.class, 1, DAYS).build();
// Enqueue work request
WorkManager workManager = WorkManager.getInstance(context);
workManager.enqueueUniquePeriodicWork(WORK_NAME, workPolicy, workRequest);
}
/**
* This class is a {@link Worker} to check for application update and notify them if needed.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public static class ApkUpdateWorker extends Worker {
/**
* Constructor.
*
* @param context The application context.
* @param workerParams The parameters to setup this worker.
*/
public ApkUpdateWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
Timber.i("Starting update worker");
// Create model
AdAwayApplication application = (AdAwayApplication) getApplicationContext();
UpdateModel model = application.getUpdateModel();
// Check for update
model.checkForUpdate();
Manifest manifest = model.getManifest().getValue();
if (manifest != null && manifest.updateAvailable) {
// Display update notification
NotificationHelper.showUpdateApplicationNotification(application);
}
// Return as success
return success();
}
}
}

View file

@ -0,0 +1,24 @@
package org.adaway.model.update;
import org.json.JSONException;
import org.json.JSONObject;
/**
* This class is represent an application manifest.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class Manifest {
public final String version;
public final int versionCode;
public final String changelog;
public final boolean updateAvailable;
public Manifest(String manifest, long currentVersionCode) throws JSONException {
JSONObject manifestObject = new JSONObject(manifest);
this.version = manifestObject.getString("version");
this.versionCode = manifestObject.getInt("versionCode");
this.changelog = manifestObject.getString("changelog");
this.updateAvailable = this.versionCode > currentVersionCode;
}
}

View file

@ -0,0 +1,203 @@
package org.adaway.model.update;
import static android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE;
import static android.os.Build.VERSION.SDK_INT;
import static org.adaway.model.update.UpdateStore.getApkStore;
import static java.util.Objects.requireNonNull;
import android.app.DownloadManager;
import android.content.Context;
import android.content.IntentFilter;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.adaway.R;
import org.adaway.helper.PreferenceHelper;
import org.json.JSONException;
import java.io.IOException;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import timber.log.Timber;
/**
* This class is the model in charge of updating the application.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class UpdateModel {
private static final String MANIFEST_URL = "https://app.adaway.org/manifest.json";
private static final String DOWNLOAD_URL = "https://app.adaway.org/adaway.apk?versionCode=";
private final Context context;
private final VersionInfo versionInfo;
private final OkHttpClient client;
private final MutableLiveData<Manifest> manifest;
private ApkDownloadReceiver receiver;
/**
* Constructor.
*
* @param context The application context.
*/
public UpdateModel(Context context) {
this.context = context;
this.versionInfo = VersionInfo.get(context);
this.manifest = new MutableLiveData<>();
this.client = buildHttpClient();
ApkUpdateService.syncPreferences(context);
}
/**
* Get the current version code.
*
* @return The current version code.
*/
public int getVersionCode() {
return this.versionInfo.code;
}
/**
* Get the current version name.
*
* @return The current version name.
*/
public String getVersionName() {
return this.versionInfo.name;
}
/**
* Get the last version manifest.
*
* @return The last version manifest.
*/
public LiveData<Manifest> getManifest() {
return this.manifest;
}
/**
* Get the application update store.
*
* @return The application update store.
*/
public UpdateStore getStore() {
return getApkStore(this.context);
}
/**
* Get the application update channel.
*
* @return The application update channel.
*/
public String getChannel() {
return PreferenceHelper.getIncludeBetaReleases(this.context) ? "beta" : "stable";
}
/**
* Check if there is an update available.
*/
public void checkForUpdate() {
Manifest manifest = downloadManifest();
// Notify update
if (manifest != null) {
this.manifest.postValue(manifest);
}
}
private OkHttpClient buildHttpClient() {
return new OkHttpClient.Builder().build();
}
private Manifest downloadManifest() {
if (!this.versionInfo.isValid()) {
return null;
}
HttpUrl httpUrl = requireNonNull(HttpUrl.parse(MANIFEST_URL), "Failed to parse manifest URL")
.newBuilder()
.addQueryParameter("versionCode", Integer.toString(this.versionInfo.code))
.addQueryParameter("sdkCode", Integer.toString(SDK_INT))
.addQueryParameter("channel", getChannel())
.addQueryParameter("store", getStore().getName())
.build();
Request request = new Request.Builder()
.url(httpUrl)
.build();
try (Response execute = this.client.newCall(request).execute();
ResponseBody body = execute.body()) {
if (execute.isSuccessful() && body != null) {
return new Manifest(body.string(), this.versionInfo.code);
} else {
return null;
}
} catch (IOException | JSONException exception) {
Timber.e(exception, "Unable to download manifest.");
// Return failed
return null;
}
}
/**
* Update the application to the latest version.
*
* @return The download identifier ({@code -1} if download was not started).
*/
public long update() {
// Check manifest
Manifest manifest = this.manifest.getValue();
if (manifest == null) {
return -1;
}
// Check previous broadcast receiver
if (this.receiver != null) {
this.context.unregisterReceiver(this.receiver);
}
// Queue download
long downloadId = download(manifest);
// Register new broadcast receiver
this.receiver = new ApkDownloadReceiver(downloadId);
this.context.registerReceiver(this.receiver, new IntentFilter(ACTION_DOWNLOAD_COMPLETE));
// Return download identifier
return downloadId;
}
private long download(Manifest manifest) {
Timber.i("Downloading " + manifest.version + ".");
Uri uri = Uri.parse(DOWNLOAD_URL + manifest.versionCode);
DownloadManager.Request request = new DownloadManager.Request(uri)
.setTitle("AdAway " + manifest.version)
.setDescription(this.context.getString(R.string.update_notification_description));
DownloadManager downloadManager = this.context.getSystemService(DownloadManager.class);
return downloadManager.enqueue(request);
}
private static class VersionInfo {
private final int code;
private final String name;
private VersionInfo(int code, String name) {
this.code = code;
this.name = name;
}
public static VersionInfo get(Context context) {
try {
PackageInfo packageInfo = context.getPackageManager()
.getPackageInfo(context.getPackageName(), 0);
return new VersionInfo(packageInfo.versionCode, packageInfo.versionName);
} catch (PackageManager.NameNotFoundException e) {
return new VersionInfo(0, "development");
}
}
public boolean isValid() {
return this.code > 0;
}
}
}

View file

@ -0,0 +1,120 @@
package org.adaway.model.update;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import static android.content.pm.PackageManager.GET_SIGNATURES;
import static android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES;
import static android.os.Build.VERSION.SDK_INT;
import static android.os.Build.VERSION_CODES.P;
import timber.log.Timber;
/**
* This enumerates represents the stores to get AdAway updates.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public enum UpdateStore {
/**
* The official store (usually GitHub releases) with AdAway signing key.
*/
ADAWAY("adaway", "D647FDAC42961502AC78F99919B8E1901747E8DA78FE13E1EABA688FECC4C99E"),
/**
* The F-Droid store with F-Droid signing key.
*/
F_DROID("fdroid", "42203F1AC857426D1496E971DB96FBE1F88C25C9E1F895A5C98D703891292277"),
/**
* An unknown store.
*/
UNKNOWN("unknown", "");
/**
* The store name.
*/
public final String storeName;
/**
* The store singing certificate digest.
*/
public final String sign;
UpdateStore(String name, String sign) {
this.storeName = name;
this.sign = sign;
}
/**
* Get the store of the running application.
*
* @param context The application context.
* @return The application store, {@link #UNKNOWN} if store can't be defined.
*/
@SuppressLint("PackageManagerGetSignatures")
public static UpdateStore getApkStore(Context context) {
PackageManager packageManager = context.getPackageManager();
String packageName = context.getPackageName();
Signature[] signatures;
try {
if (SDK_INT >= P) {
signatures = packageManager.getPackageInfo(
packageName,
GET_SIGNING_CERTIFICATES
).signingInfo.getSigningCertificateHistory();
} else {
// Signatures are not used for security reason. Only to guess the flavor of the app.
signatures = packageManager.getPackageInfo(
packageName,
GET_SIGNATURES
).signatures;
}
} catch (PackageManager.NameNotFoundException e) {
Timber.w(e, "Failed to get application package info.");
return UpdateStore.UNKNOWN;
}
return UpdateStore.getFromSigns(signatures);
}
private static UpdateStore getFromSigns(Signature[] signatures) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
for (Signature signature : signatures) {
md.update(signature.toByteArray());
String sign = bytesToHex(md.digest());
for (UpdateStore store : UpdateStore.values()) {
if (store.sign.equals(sign)) {
return store;
}
}
}
} catch (NoSuchAlgorithmException e) {
Timber.w(e, "SHA-256 algorithm is no supported.");
}
return UpdateStore.UNKNOWN;
}
private static String bytesToHex(byte[] bytes) {
final char[] hexArray = {'0', '1', '2', '3', '4', '5', '6', '7', '8',
'9', 'A', 'B', 'C', 'D', 'E', 'F'};
char[] hexChars = new char[bytes.length * 2];
int v;
for (int j = 0; j < bytes.length; j++) {
v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
/**
* Get the store name.
*
* @return The store name.
*/
public String getName() {
return this.storeName;
}
}

View file

@ -0,0 +1,124 @@
package org.adaway.model.vpn;
import static org.adaway.model.adblocking.AdBlockMethod.VPN;
import static org.adaway.model.error.HostError.ENABLE_VPN_FAIL;
import android.content.Context;
import android.util.LruCache;
import org.adaway.R;
import org.adaway.db.AppDatabase;
import org.adaway.db.dao.HostEntryDao;
import org.adaway.db.entity.HostEntry;
import org.adaway.model.adblocking.AdBlockMethod;
import org.adaway.model.adblocking.AdBlockModel;
import org.adaway.model.error.HostErrorException;
import org.adaway.vpn.VpnServiceControls;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import timber.log.Timber;
/**
* This class is the model to represent VPN service configuration.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class VpnModel extends AdBlockModel {
private final HostEntryDao hostEntryDao;
private final LruCache<String, HostEntry> blockCache;
private final LinkedHashSet<String> logs;
private boolean recordingLogs;
private int requestCount;
/**
* Constructor.
*
* @param context The application context.
*/
public VpnModel(Context context) {
super(context);
AppDatabase database = AppDatabase.getInstance(context);
this.hostEntryDao = database.hostEntryDao();
this.blockCache = new LruCache<String, HostEntry>(4 * 1024) {
@Override
protected HostEntry create(String key) {
return VpnModel.this.hostEntryDao.getEntry(key);
}
};
this.logs = new LinkedHashSet<>();
this.recordingLogs = false;
this.requestCount = 0;
this.applied.postValue(VpnServiceControls.isRunning(context));
}
@Override
public AdBlockMethod getMethod() {
return VPN;
}
@Override
public void apply() throws HostErrorException {
// Clear cache
this.blockCache.evictAll();
// Start VPN
boolean started = VpnServiceControls.start(this.context);
this.applied.postValue(started);
if (!started) {
throw new HostErrorException(ENABLE_VPN_FAIL);
}
setState(R.string.status_vpn_configuration_updated);
}
@Override
public void revert() {
VpnServiceControls.stop(this.context);
this.applied.postValue(false);
}
@Override
public boolean isRecordingLogs() {
return this.recordingLogs;
}
@Override
public void setRecordingLogs(boolean recording) {
this.recordingLogs = recording;
}
@Override
public List<String> getLogs() {
return new ArrayList<>(this.logs);
}
@Override
public void clearLogs() {
this.logs.clear();
}
/**
* Checks host entry related to an host name.
*
* @param host A hostname to check.
* @return The related host entry.
*/
public HostEntry getEntry(String host) {
// Compute miss rate periodically
this.requestCount++;
if (this.requestCount >= 1000) {
int hits = this.blockCache.hitCount();
int misses = this.blockCache.missCount();
double missRate = 100D * (hits + misses) / misses;
Timber.d("Host cache miss rate: %s.", missRate);
this.requestCount = 0;
}
// Add host to logs
if (this.recordingLogs) {
this.logs.add(host);
}
// Check cache
return this.blockCache.get(host);
}
}

View file

@ -0,0 +1,81 @@
package org.adaway.tile;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
import androidx.lifecycle.LiveData;
import org.adaway.AdAwayApplication;
import org.adaway.model.adblocking.AdBlockModel;
import org.adaway.model.error.HostErrorException;
import org.adaway.util.AppExecutors;
import java.util.concurrent.atomic.AtomicBoolean;
import static android.service.quicksettings.Tile.STATE_ACTIVE;
import static android.service.quicksettings.Tile.STATE_INACTIVE;
import timber.log.Timber;
/**
* This class is a {@link TileService} to toggle ad-blocking.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class AdBlockingTileService extends TileService {
private final AtomicBoolean toggling = new AtomicBoolean(false);
@Override
public void onTileAdded() {
boolean adBlocked = getModel().isApplied().getValue() == Boolean.TRUE;
updateTile(adBlocked);
}
@Override
public void onStartListening() {
LiveData<Boolean> applied = getModel().isApplied();
applied.observeForever(this::updateTile);
}
@Override
public void onStopListening() {
LiveData<Boolean> applied = getModel().isApplied();
applied.removeObserver(this::updateTile);
}
@Override
public void onClick() {
AppExecutors.getInstance()
.diskIO()
.execute(this::toggleAdBlocking);
}
private void updateTile(boolean adBlocked) {
Tile tile = getQsTile();
tile.setState(adBlocked ? STATE_ACTIVE : STATE_INACTIVE);
tile.updateTile();
}
private void toggleAdBlocking() {
if (this.toggling.get()) {
return;
}
AdBlockModel model = getModel();
try {
this.toggling.set(true);
if (model.isApplied().getValue() == Boolean.TRUE) {
model.revert();
} else {
model.apply();
}
} catch (HostErrorException e) {
Timber.w(e, "Failed to toggle ad-blocking.");
} finally {
this.toggling.set(false);
}
}
private AdBlockModel getModel() {
return ((AdAwayApplication) getApplication()).getAdBlockModel();
}
}

View file

@ -0,0 +1,104 @@
package org.adaway.ui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.view.View;
import static android.view.View.ALPHA;
import static android.view.View.GONE;
import static android.view.View.INVISIBLE;
import static android.view.View.VISIBLE;
/**
* This class is an utility class to animate views.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public final class Animations {
private Animations() {
}
/**
* Animate view to be shown.
*
* @param view The view to animate.
*/
public static void showView(View view) {
ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, ALPHA, 1F);
objectAnimator.setAutoCancel(true);
objectAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
view.setVisibility(VISIBLE);
}
});
objectAnimator.start();
}
/**
* Animate view to be hidden.
*
* @param view The view to animate.
*/
public static void hideView(View view) {
ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, ALPHA, 0F);
objectAnimator.setAutoCancel(true);
objectAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
view.setVisibility(INVISIBLE);
}
});
objectAnimator.start();
}
/**
* Animate view to be removed.
*
* @param view The view to animate.
*/
public static void removeView(View view) {
ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, ALPHA, 0F);
objectAnimator.setAutoCancel(true);
objectAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
view.setVisibility(GONE);
}
});
objectAnimator.start();
}
/**
* Immediately set view to shown state.
*
* @param view The view to set.
*/
public static void setShown(View view) {
view.setVisibility(VISIBLE);
view.setAlpha(1f);
}
/**
* Immediately set view to hidden state.
*
* @param view The view to set.
*/
public static void setHidden(View view) {
view.setVisibility(INVISIBLE);
view.setAlpha(0f);
}
/**
* Immediately set view to gone state.
*
* @param view The view to set.
*/
public static void setRemoved(View view) {
view.setVisibility(GONE);
view.setAlpha(0f);
}
}

View file

@ -0,0 +1,191 @@
package org.adaway.ui.adblocking;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ProgressBar;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.Observer;
import com.google.android.material.snackbar.Snackbar;
import org.adaway.AdAwayApplication;
import org.adaway.R;
import org.adaway.model.adblocking.AdBlockModel;
import org.adaway.model.error.HostErrorException;
import org.adaway.model.source.SourceModel;
import org.adaway.util.AppExecutors;
import java.util.Collection;
import static com.google.android.material.snackbar.Snackbar.LENGTH_INDEFINITE;
import static com.google.android.material.snackbar.Snackbar.LENGTH_LONG;
/**
* This class is a {@link Snackbar} to notify about adblock model new configuration to apply.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class ApplyConfigurationSnackbar {
/**
* The view to bind the snackbar to.
*/
private final View view;
/**
* The notify snackbar when hosts update available.
*/
private final Snackbar notifySnackbar;
/**
* The wait snackbar during hosts install.
*/
private final Snackbar waitSnackbar;
/**
* To synchronize sources before installing or not.
*/
private final boolean syncSources;
/**
* The current hosts update available status ({@code true} if update available, {@code false} otherwise).
*/
private boolean update;
/**
* Whether or not ignore the next update event ({@code true} to ignore, {@code false} otherwise).
*/
private boolean skipUpdate;
/**
* Whether or not ignore update events during the install ({@code true} to ignore, {@code false} otherwise).
*/
private boolean ignoreEventDuringInstall;
/**
* Constructor.
*
* @param view The view to bind the snackbar to.
* @param syncSources To synchronize sources before installing or not.
* @param ignoreEventDuringInstall {@code true} to ignore events, {@code false} otherwise.
*/
public ApplyConfigurationSnackbar(@NonNull View view, boolean syncSources, boolean ignoreEventDuringInstall) {
this.view = view;
this.notifySnackbar = Snackbar.make(this.view, R.string.notification_configuration_changed, LENGTH_INDEFINITE)
.setAction(R.string.notification_configuration_changed_action, v -> apply());
this.waitSnackbar = Snackbar.make(this.view, R.string.notification_configuration_installing, LENGTH_INDEFINITE);
appendViewToSnackbar(this.waitSnackbar, new ProgressBar(this.view.getContext()));
this.syncSources = syncSources;
this.ignoreEventDuringInstall = ignoreEventDuringInstall;
this.update = false;
this.skipUpdate = false;
}
/**
* Create {@link Observer} which ignores first (initialization) event.
*
* @param <T> The type of data to observe.
* @return The observer instance.
*/
public <T> Observer<T> createObserver() {
return new Observer<T>() {
boolean firstUpdate = true;
@Override
public void onChanged(@Nullable T t) {
// Check new data
if (t == null || (t instanceof Collection && ((Collection<?>) t).isEmpty())) {
return;
}
// First update
if (this.firstUpdate) {
this.firstUpdate = false;
return;
}
ApplyConfigurationSnackbar.this.notifyUpdateAvailable();
}
};
}
/**
* Notify update available.
*/
public void notifyUpdateAvailable() {
// Check if notify snackbar is already displayed
if (this.notifySnackbar.isShown()) {
return;
}
// Check if wait snackbar is displayed
if (this.waitSnackbar.isShown()) {
// Mark update available
this.update = true;
return;
}
// Check if update event should be skipped
if (this.skipUpdate) {
this.skipUpdate = false;
return;
}
// Show notify snackbar
this.notifySnackbar.show();
// Mark update as notified
this.update = false;
}
private void apply() {
showLoading();
AppExecutors.getInstance().diskIO().execute(() -> {
AdAwayApplication application = (AdAwayApplication) this.view.getContext().getApplicationContext();
SourceModel sourceModel = application.getSourceModel();
AdBlockModel adBlockModel = application.getAdBlockModel();
try {
if (this.syncSources) {
sourceModel.retrieveHostsSources();
} else {
sourceModel.syncHostEntries();
}
adBlockModel.apply();
endLoading(true);
} catch (HostErrorException exception) {
endLoading(false);
}
});
}
private void showLoading() {
// Clear notify snackbar
this.notifySnackbar.dismiss();
// Show wait snackbar
this.waitSnackbar.show();
}
private void endLoading(boolean successfulInstall) {
// Ensure the snackbar has time to display
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Clear snackbars
this.waitSnackbar.dismiss();
// Check install failure
if (!successfulInstall) {
Snackbar failureSnackbar = Snackbar.make(this.view, R.string.notification_configuration_failed, LENGTH_LONG);
ImageView view = new ImageView(this.view.getContext());
view.setImageResource(R.drawable.ic_error_outline_24dp);
appendViewToSnackbar(failureSnackbar, view);
failureSnackbar.show();
}
// Check pending update notification
else if (this.update) {
// Ignore next update event if events should be ignored
if (this.ignoreEventDuringInstall) {
this.skipUpdate = true;
} else {
// Otherwise display update notification
notifyUpdateAvailable();
}
}
}
private void appendViewToSnackbar(Snackbar snackbar, View view) {
ViewGroup viewGroup = (ViewGroup) snackbar.getView().findViewById(com.google.android.material.R.id.snackbar_text).getParent();
viewGroup.addView(view);
}
}

View file

@ -0,0 +1,145 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.adware;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListView;
import android.widget.SimpleAdapter;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import org.adaway.R;
import java.util.List;
/**
* This class is a {@link Fragment} to scan and uninstall adware.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class AdwareFragment extends Fragment {
/**
* The adware install list view.
*/
private ListView mListView;
/**
* The status text.
*/
private TextView mStatusText;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
// Create fragment view
View view = inflater.inflate(R.layout.adware_fragment, container, false);
// Get list view
this.mListView = view.findViewById(R.id.adware_list);
// Bind list onclick listener
this.mListView.setOnItemClickListener((parent, view1, position, id) -> {
// Get clicked adware
AdwareInstall adwareInstall = (AdwareInstall) parent.getItemAtPosition(position);
// Uninstall adware
AdwareFragment.this.uninstallAdware(adwareInstall);
});
// Get status text
this.mStatusText = view.findViewById(R.id.adware_status_text);
/*
* Get model and bind it to view.
*/
// Get the model scope
FragmentActivity activity = requireActivity();
// Get the model
AdwareViewModel model = new ViewModelProvider(activity).get(AdwareViewModel.class);
// Bind model to views
model.getAdware().observe(getViewLifecycleOwner(), data -> {
if (data == null) {
this.displayStatusText(R.string.adware_scanning);
} else if (data.isEmpty()) {
this.displayStatusText(R.string.adware_empty);
} else {
this.displayAdware(data);
}
});
// Return created view
return view;
}
/**
* Display a status text.
*
* @param text The status text to display.
*/
private void displayStatusText(int text) {
// Set text
this.mStatusText.setText(text);
// Show the text
this.mStatusText.setVisibility(View.VISIBLE);
this.mListView.setVisibility(View.GONE);
}
/**
* Display the installed adware.
*
* @param data The adware to show.
*/
private void displayAdware(List<AdwareInstall> data) {
// Create adapter
String[] from = new String[]{
AdwareInstall.APPLICATION_NAME_KEY,
AdwareInstall.PACKAGE_NAME_KEY
};
int[] to = new int[]{
R.id.checkbox_list_text,
R.id.checkbox_list_subtext
};
SimpleAdapter adapter = new SimpleAdapter(this.getContext(),
data,
R.layout.list_two_entries,
from,
to
);
// Update list
adapter.notifyDataSetChanged();
// Show the list
this.mListView.setAdapter(adapter);
this.mStatusText.setVisibility(View.GONE);
this.mListView.setVisibility(View.VISIBLE);
}
/**
* Uninstall adware.
*
* @param adwareInstall The adware to uninstall.
*/
private void uninstallAdware(AdwareInstall adwareInstall) {
Intent intent = new Intent(Intent.ACTION_DELETE);
intent.setData(Uri.parse("package:" + adwareInstall.get(AdwareInstall.PACKAGE_NAME_KEY)));
this.startActivity(intent);
}
}

View file

@ -0,0 +1,43 @@
package org.adaway.ui.adware;
import androidx.annotation.NonNull;
import java.util.HashMap;
/**
* This class is a POJO to represent an installed adware.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
class AdwareInstall extends HashMap<String, String> implements Comparable<AdwareInstall> {
/**
* The adware application name.
*/
final static String APPLICATION_NAME_KEY = "app_name";
/**
* The adware package name.
*/
final static String PACKAGE_NAME_KEY = "package_name";
/**
* Constructor.
*
* @param applicationName The adware application name.
* @param packageName The adware package name.
*/
AdwareInstall(String applicationName, String packageName) {
super(2);
this.put(AdwareInstall.APPLICATION_NAME_KEY, applicationName);
this.put(AdwareInstall.PACKAGE_NAME_KEY, packageName);
}
@Override
public int compareTo(@NonNull AdwareInstall other) {
int nameComparison = this.get(AdwareInstall.APPLICATION_NAME_KEY).compareTo(other.get(AdwareInstall.APPLICATION_NAME_KEY));
if (nameComparison == 0) {
return this.get(AdwareInstall.PACKAGE_NAME_KEY).compareTo(other.get(AdwareInstall.PACKAGE_NAME_KEY));
} else {
return nameComparison;
}
}
}

View file

@ -0,0 +1,154 @@
package org.adaway.ui.adware;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.ComponentInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import org.adaway.util.AppExecutors;
import java.util.ArrayList;
import java.util.List;
import static java.util.stream.Collectors.toList;
import timber.log.Timber;
/**
* This class is {@link LiveData} to represents installed adware on device.
*/
class AdwareLiveData extends LiveData<List<AdwareInstall>> {
/**
* The adware package prefixes.
*/
private static final String[] AD_PACKAGE_PREFIXES = {
"com.airpush.",
"com.adnotify.",
"com.appbucks.sdk.",
"com.appenda.",
"com.applovin.",
"com.iac.notification.",
"com.inmobi.",
"com.Leadbolt.",
"com.sellaring.",
"com.senddroid.",
"com.tapjoy.",
"cn.kuguo."
};
/**
* The application context.
*/
private final Context context;
/**
* Constructor.
*
* @param context The application context.
*/
AdwareLiveData(Context context) {
this.context = context;
AppExecutors.getInstance().diskIO().execute(this::loadData);
}
@WorkerThread
private void loadData() {
// Get the package manager
PackageManager pm = this.context.getPackageManager();
// Get the adware packages
List<PackageInfo> adwarePackages = this.getAdwarePackages(pm);
// Create related adware installs
List<AdwareInstall> adwareInstalls = adwarePackages.stream()
.map(this::createInstallFromPackageInfo)
.sorted()
.collect(toList());
// Post loaded adware installs
this.postValue(adwareInstalls);
}
/**
* Finds all installed packages that look like they include a known ad framework
*
* @param pm The package manager.
* @return The found adware package information.
*/
private List<PackageInfo> getAdwarePackages(PackageManager pm) {
List<PackageInfo> adPackages = new ArrayList<>();
// It'd be simpler to just use pm.getInstalledPackages here, but apparently it's broken
List<ApplicationInfo> applicationInfoList = pm.getInstalledApplications(0);
for (ApplicationInfo applicationInfo : applicationInfoList) {
try {
// Retrieve package information
PackageInfo packageInfo = pm.getPackageInfo(
applicationInfo.packageName,
PackageManager.GET_ACTIVITIES | PackageManager.GET_RECEIVERS | PackageManager.GET_SERVICES
);
if (this.isAdware(packageInfo)) {
adPackages.add(packageInfo);
}
} catch (Exception exception) {
Timber.e(exception, "An error occurred while scanning applications for adware");
}
}
return adPackages;
}
/**
* Check if application is an adware.
*
* @param info The application package information.
* @return <code>true</code> if the application is an adware, <code>false</code> otherwise.
*/
private boolean isAdware(PackageInfo info) {
// Get package name
String packageName = info.packageName;
Timber.v("Scanning package %s", packageName);
// Check package components
boolean matchActivity = info.activities != null && checkComponent(packageName, "activity", info.activities);
boolean matchReceiver = info.receivers != null && checkComponent(packageName, "receiver", info.receivers);
boolean matchService = info.services != null && checkComponent(packageName, "service", info.services);
return matchActivity || matchReceiver || matchService;
}
/**
* Check if an application component match the adware signature.
*
* @param packageName The application package name.
* @param type The component type.
* @param info The application components to check.
* @return <code>true</code> if a component matches adware signature, <code>false</code> otherwise.
*/
private boolean checkComponent(String packageName, String type, ComponentInfo[] info) {
for (ComponentInfo componentInfo : info) {
String componentName = componentInfo.name;
Timber.v("[%s] %s", type, componentName);
for (String adPackagePrefix : AD_PACKAGE_PREFIXES) {
if (componentName.startsWith(adPackagePrefix)) {
Timber.i("Detected ad framework prefix %s in package %s as %s %s", adPackagePrefix, packageName, type, componentName);
return true;
}
}
}
return false;
}
/**
* Create {@link AdwareInstall} from {@link PackageInfo}.
*
* @param packageInfo The package info to convert.
* @return The related adware install.
*/
private AdwareInstall createInstallFromPackageInfo(PackageInfo packageInfo) {
// Get the package manager
PackageManager pm = this.context.getPackageManager();
// Retrieve application name
String applicationName = pm.getApplicationLabel(packageInfo.applicationInfo).toString();
// Add adware install
return new AdwareInstall(applicationName, packageInfo.packageName);
}
}

View file

@ -0,0 +1,37 @@
package org.adaway.ui.adware;
import android.app.Application;
import androidx.lifecycle.AndroidViewModel;
import androidx.annotation.NonNull;
/**
* This class is a {@link androidx.lifecycle.ViewModel} for adware UI.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class AdwareViewModel extends AndroidViewModel {
/**
* The install adware.
*/
private final AdwareLiveData adware;
/**
* Constructor.
*
* @param application The application context.
*/
public AdwareViewModel(@NonNull Application application) {
super(application);
this.adware = new AdwareLiveData(application);
}
/**
* Get the installed adware.
*
* @return The installed adware.
*/
public AdwareLiveData getAdware() {
return this.adware;
}
}

View file

@ -0,0 +1,55 @@
package org.adaway.ui.dialog;
import androidx.appcompat.app.AlertDialog;
import androidx.arch.core.util.Function;
import android.content.DialogInterface;
import android.text.Editable;
import android.text.TextWatcher;
import android.widget.Button;
/**
* This class is a {@link TextWatcher} to validate an alert dialog field.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class AlertDialogValidator implements TextWatcher {
/**
* The button to change status.
*/
private final Button mButton;
/**
* The field validator.
*/
private final Function<String, Boolean> validator;
/**
* Constructor.
*
* @param dialog The button to change status.
* @param validator The field validator.
* @param initialState The validation initial state.
*/
public AlertDialogValidator(AlertDialog dialog, Function<String, Boolean> validator, boolean initialState) {
this.mButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
this.mButton.setEnabled(initialState);
this.validator = validator;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
String url = s.toString();
this.mButton.setEnabled(this.validator.apply(url));
}
}

View file

@ -0,0 +1,110 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.dialog;
import android.app.SearchManager;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.StringRes;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.adaway.R;
import timber.log.Timber;
/**
* This class is an utility class to help install missing applications.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class MissingAppDialog {
/**
* Show a dialog to install a text editor.
*
* @param context The application context.
*/
public static void showTextEditorMissingDialog(Context context) {
showMissingAppDialog(
context,
R.string.no_text_editor_title,
R.string.no_text_editor,
"market://details?id=jp.sblo.pandora.jota",
"Text Edit"
);
}
/**
* Show a dialog to install a file manager.
*
* @param context The application context.
*/
public static void showFileManagerMissingDialog(Context context) {
showMissingAppDialog(
context,
R.string.no_file_manager_title,
R.string.no_file_manager,
"market://details?id=org.openintents.filemanager",
"OI File Manager"
);
}
private static void showMissingAppDialog(
Context context,
@StringRes int title,
@StringRes int message,
String appGooglePlayUri,
String appFdroidQuery
) {
new MaterialAlertDialogBuilder(context)
.setTitle(title)
.setMessage(message)
.setIcon(android.R.drawable.ic_dialog_alert)
.setPositiveButton(R.string.button_yes, (dialog, id) -> {
Intent intentGooglePlay = new Intent(Intent.ACTION_VIEW);
intentGooglePlay.setData(Uri.parse(appGooglePlayUri));
try {
context.startActivity(intentGooglePlay);
} catch (ActivityNotFoundException e) {
Timber.e(e, "No Google Play Store installed!, Trying FDroid...");
Intent intentFDroid = new Intent(Intent.ACTION_SEARCH);
intentFDroid.setComponent(new ComponentName("org.fdroid.fdroid",
"org.fdroid.fdroid.SearchResults"));
intentFDroid.putExtra(SearchManager.QUERY, appFdroidQuery);
try {
context.startActivity(intentFDroid);
} catch (ActivityNotFoundException e2) {
Timber.e(e2, "No FDroid installed!");
}
}
})
.setNegativeButton(R.string.button_no, (dialog, id) -> dialog.dismiss())
.create()
.show();
}
}

View file

@ -0,0 +1,108 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.help;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
import org.adaway.R;
import org.adaway.helper.ThemeHelper;
public class HelpActivity extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeHelper.applyTheme(this);
setContentView(R.layout.help_activity);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayShowTitleEnabled(true);
actionBar.setDisplayHomeAsUpEnabled(true);
}
ViewPager2 viewPager = findViewById(R.id.pager);
viewPager.setAdapter(new TabsAdapter(this));
TabLayout tabLayout = findViewById(R.id.tabLayout);
new TabLayoutMediator(
tabLayout,
viewPager,
(tab, position) -> tab.setText(getTabName(position))
).attach();
}
private @StringRes
int getTabName(int position) {
switch (position) {
case 0:
return R.string.help_tab_faq;
case 1:
return R.string.help_tab_problems;
case 2:
return R.string.help_tab_s_on_s_off;
default:
throw new IllegalStateException("Position " + position + " is not supported.");
}
}
private static class TabsAdapter extends FragmentStateAdapter {
private final Fragment faqFragment = HelpFragmentHtml.newInstance(R.raw.help_faq);
private final Fragment problemsFragment = HelpFragmentHtml.newInstance(R.raw.help_problems);
private final Fragment sonSofFragment = HelpFragmentHtml.newInstance(R.raw.help_s_on_s_off);
TabsAdapter(@NonNull FragmentActivity fragmentActivity) {
super(fragmentActivity);
}
@NonNull
@Override
public Fragment createFragment(int position) {
switch (position) {
case 0:
return this.faqFragment;
case 1:
return this.problemsFragment;
case 2:
return this.sonSofFragment;
default:
throw new IllegalStateException("Position " + position + " is not supported.");
}
}
@Override
public int getItemCount() {
return 3;
}
}
}

View file

@ -0,0 +1,96 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.help;
import android.os.Bundle;
import android.text.Html;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.RawRes;
import androidx.fragment.app.Fragment;
import org.adaway.R;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import static android.text.Html.FROM_HTML_MODE_LEGACY;
import timber.log.Timber;
public class HelpFragmentHtml extends Fragment {
private static final String TAG = "Help";
private static final String ARG_HTML_FILE = "htmlFile";
/**
* Create a new instance of HelpFragmentHtml, providing "htmlFile" as an argument.
*/
static HelpFragmentHtml newInstance(@RawRes int htmlFile) {
HelpFragmentHtml instance = new HelpFragmentHtml();
// Supply html raw file input as an argument.
Bundle args = new Bundle();
args.putInt(ARG_HTML_FILE, htmlFile);
instance.setArguments(args);
return instance;
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
Spanned spanned = new SpannableString("");
if (getArguments() != null) {
int htmlFile = getArguments().getInt(ARG_HTML_FILE);
try {
spanned = Html.fromHtml(readHtmlRawFile(htmlFile), FROM_HTML_MODE_LEGACY);
} catch (IOException e) {
Timber.w("Failed to read help file.");
}
}
View view = inflater.inflate(R.layout.help_fragment, container, false);
TextView helpTextView = view.findViewById(R.id.helpTextView);
helpTextView.setText(spanned);
helpTextView.setMovementMethod(LinkMovementMethod.getInstance());
return view;
}
private String readHtmlRawFile(@RawRes int resourceId) throws IOException {
try (InputStream inputStream = getResources().openRawResource(resourceId);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
StringBuilder content = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
content.append(line);
}
return content.toString();
}
}
}

View file

@ -0,0 +1,366 @@
package org.adaway.ui.home;
import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED;
import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN;
import static org.adaway.model.adblocking.AdBlockMethod.UNDEFINED;
import static org.adaway.model.adblocking.AdBlockMethod.VPN;
import static org.adaway.ui.Animations.removeView;
import static org.adaway.ui.Animations.showView;
import static org.adaway.ui.lists.ListsActivity.ALLOWED_HOSTS_TAB;
import static org.adaway.ui.lists.ListsActivity.BLOCKED_HOSTS_TAB;
import static org.adaway.ui.lists.ListsActivity.REDIRECTED_HOSTS_TAB;
import static org.adaway.ui.lists.ListsActivity.TAB;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.Typeface;
import android.net.Uri;
import android.net.VpnService;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.adaway.R;
import org.adaway.databinding.HomeActivityBinding;
import org.adaway.helper.NotificationHelper;
import org.adaway.helper.PreferenceHelper;
import org.adaway.helper.ThemeHelper;
import org.adaway.model.adblocking.AdBlockMethod;
import org.adaway.model.error.HostError;
import org.adaway.ui.help.HelpActivity;
import org.adaway.ui.hosts.HostsSourcesActivity;
import org.adaway.ui.lists.ListsActivity;
import org.adaway.ui.log.LogActivity;
import org.adaway.ui.prefs.PrefsActivity;
import org.adaway.ui.support.SupportActivity;
import org.adaway.ui.update.UpdateActivity;
import org.adaway.ui.welcome.WelcomeActivity;
import kotlin.jvm.functions.Function1;
import timber.log.Timber;
/**
* This class is the application main activity.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class HomeActivity extends AppCompatActivity {
/**
* The project link.
*/
private static final String PROJECT_LINK = "https://github.com/AdAway/AdAway";
private HomeActivityBinding binding;
private BottomSheetBehavior<View> drawerBehavior;
private OnBackPressedCallback onBackPressedCallback;
private HomeViewModel homeViewModel;
private ActivityResultLauncher<Intent> prepareVpnLauncher;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeHelper.applyTheme(this);
NotificationHelper.clearUpdateNotifications(this);
Timber.i("Starting main activity");
this.binding = HomeActivityBinding.inflate(getLayoutInflater());
setContentView(this.binding.getRoot());
this.homeViewModel = new ViewModelProvider(this).get(HomeViewModel.class);
this.homeViewModel.isAdBlocked().observe(this, this::notifyAdBlocked);
this.homeViewModel.getError().observe(this, this::notifyError);
applyActionBar();
bindAppVersion();
bindHostCounter();
bindSourceCounter();
bindPending();
bindState();
bindClickListeners();
setUpBottomDrawer();
bindFab();
this.binding.navigationView.setNavigationItemSelectedListener(item -> {
if (showFragment(item.getItemId())) {
this.drawerBehavior.setState(STATE_HIDDEN);
}
return false; // TODO Handle selection
});
this.prepareVpnLauncher = registerForActivityResult(new StartActivityForResult(), result -> {
});
if (savedInstanceState == null) {
checkUpdateAtStartup();
}
}
@Override
protected void onResume() {
super.onResume();
checkFirstStep();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
return showFragment(item.getItemId());
}
private void checkFirstStep() {
AdBlockMethod adBlockMethod = PreferenceHelper.getAdBlockMethod(this);
Intent prepareIntent;
if (adBlockMethod == UNDEFINED) {
// Start welcome activity
startActivity(new Intent(this, WelcomeActivity.class));
finish();
} else if (adBlockMethod == VPN && (prepareIntent = VpnService.prepare(this)) != null) {
// Prepare VPN
this.prepareVpnLauncher.launch(prepareIntent);
}
}
private void checkUpdateAtStartup() {
boolean checkAppUpdateAtStartup = PreferenceHelper.getUpdateCheckAppStartup(this);
if (checkAppUpdateAtStartup) {
this.homeViewModel.checkForAppUpdate();
}
boolean checkUpdateAtStartup = PreferenceHelper.getUpdateCheck(this);
if (checkUpdateAtStartup) {
this.homeViewModel.update();
}
}
private void applyActionBar() {
setSupportActionBar(this.binding.bar);
}
private void bindAppVersion() {
TextView versionTextView = this.binding.content.versionTextView;
versionTextView.setText(this.homeViewModel.getVersionName());
versionTextView.setOnClickListener(this::showUpdate);
this.homeViewModel.getAppManifest().observe(
this,
manifest -> {
if (manifest.updateAvailable) {
versionTextView.setTypeface(versionTextView.getTypeface(), Typeface.BOLD);
versionTextView.setText(R.string.update_available);
}
}
);
}
private void bindHostCounter() {
Function1<Integer, CharSequence> stringMapper = count -> Integer.toString(count);
TextView blockedHostCountTextView = this.binding.content.blockedHostCounterTextView;
LiveData<Integer> blockedHostCount = this.homeViewModel.getBlockedHostCount();
Transformations.map(blockedHostCount, stringMapper).observe(this, blockedHostCountTextView::setText);
TextView allowedHostCountTextView = this.binding.content.allowedHostCounterTextView;
LiveData<Integer> allowedHostCount = this.homeViewModel.getAllowedHostCount();
Transformations.map(allowedHostCount, stringMapper).observe(this, allowedHostCountTextView::setText);
TextView redirectHostCountTextView = this.binding.content.redirectHostCounterTextView;
LiveData<Integer> redirectHostCount = this.homeViewModel.getRedirectHostCount();
Transformations.map(redirectHostCount, stringMapper).observe(this, redirectHostCountTextView::setText);
}
private void bindSourceCounter() {
Resources resources = getResources();
TextView upToDateSourcesTextView = this.binding.content.upToDateSourcesTextView;
LiveData<Integer> upToDateSourceCount = this.homeViewModel.getUpToDateSourceCount();
upToDateSourceCount.observe(this, count ->
upToDateSourcesTextView.setText(resources.getQuantityString(R.plurals.up_to_date_source_label, count, count))
);
TextView outdatedSourcesTextView = this.binding.content.outdatedSourcesTextView;
LiveData<Integer> outdatedSourceCount = this.homeViewModel.getOutdatedSourceCount();
outdatedSourceCount.observe(this, count ->
outdatedSourcesTextView.setText(resources.getQuantityString(R.plurals.outdated_source_label, count, count))
);
}
private void bindPending() {
this.homeViewModel.getPending().observe(this, pending -> {
if (pending) {
showView(this.binding.content.sourcesProgressBar);
showView(this.binding.content.stateTextView);
} else {
removeView(this.binding.content.sourcesProgressBar);
}
});
}
private void bindState() {
this.homeViewModel.getState().observe(this, text -> {
this.binding.content.stateTextView.setText(text);
if (text.isEmpty()) {
removeView(this.binding.content.stateTextView);
} else {
showView(this.binding.content.stateTextView);
}
});
}
private void bindClickListeners() {
this.binding.content.blockedHostCardView.setOnClickListener(v -> startHostListActivity(BLOCKED_HOSTS_TAB));
this.binding.content.allowedHostCardView.setOnClickListener(v -> startHostListActivity(ALLOWED_HOSTS_TAB));
this.binding.content.redirectHostCardView.setOnClickListener(v -> startHostListActivity(REDIRECTED_HOSTS_TAB));
this.binding.content.sourcesCardView.setOnClickListener(this::startHostsSourcesActivity);
this.binding.content.checkForUpdateImageView.setOnClickListener(v -> this.homeViewModel.update());
this.binding.content.updateImageView.setOnClickListener(v -> this.homeViewModel.sync());
this.binding.content.logCardView.setOnClickListener(this::startDnsLogActivity);
this.binding.content.helpCardView.setOnClickListener(this::startHelpActivity);
this.binding.content.supportCardView.setOnClickListener(this::showSupportActivity);
}
private void setUpBottomDrawer() {
this.drawerBehavior = BottomSheetBehavior.from(this.binding.bottomDrawer);
this.drawerBehavior.setState(STATE_HIDDEN);
this.onBackPressedCallback = new OnBackPressedCallback(false) {
@Override
public void handleOnBackPressed() {
// Hide drawer if expanded
HomeActivity.this.drawerBehavior.setState(STATE_HIDDEN);
HomeActivity.this.onBackPressedCallback.setEnabled(false);
}
};
getOnBackPressedDispatcher().addCallback(this.onBackPressedCallback);
this.binding.bar.setNavigationOnClickListener(v -> {
this.drawerBehavior.setState(STATE_HALF_EXPANDED);
this.onBackPressedCallback.setEnabled(true);
});
// this.binding.bar.setNavigationIcon(R.drawable.ic_menu_24dp);
// this.binding.bar.replaceMenu(R.menu.next_actions);
}
private void bindFab() {
this.binding.fab.setOnClickListener(v -> this.homeViewModel.toggleAdBlocking());
}
private boolean showFragment(@IdRes int actionId) {
if (actionId == R.id.drawer_preferences) {
startPrefsActivity();
this.drawerBehavior.setState(STATE_HIDDEN);
return true;
} else if (actionId == R.id.drawer_github_project) {
showProjectPage();
this.drawerBehavior.setState(STATE_HIDDEN);
return true;
}
return false;
}
/**
* Start hosts lists activity.
*
* @param tab The tab to show.
*/
private void startHostListActivity(int tab) {
Intent intent = new Intent(this, ListsActivity.class);
intent.putExtra(TAB, tab);
startActivity(intent);
}
/**
* Start hosts source activity.
*
* @param view The event source view.
*/
private void startHostsSourcesActivity(View view) {
startActivity(new Intent(this, HostsSourcesActivity.class));
}
/**
* Start help activity.
*
* @param view The source event view.
*/
private void startHelpActivity(View view) {
startActivity(new Intent(this, HelpActivity.class));
}
/**
* Show development project page.
*/
private void showProjectPage() {
// Show development page
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(PROJECT_LINK));
startActivity(browserIntent);
}
/**
* Show support activity.
*
* @param view The source event view.
*/
private void showSupportActivity(View view) {
startActivity(new Intent(this, SupportActivity.class));
}
/**
* Start preferences activity.
*/
private void startPrefsActivity() {
startActivity(new Intent(this, PrefsActivity.class));
}
/**
* Start DNS log activity.
*
* @param view The source event view.
*/
private void startDnsLogActivity(View view) {
startActivity(new Intent(this, LogActivity.class));
}
private void notifyAdBlocked(boolean adBlocked) {
int color = adBlocked ? getResources().getColor(R.color.primary, null) : Color.GRAY;
this.binding.content.headerFrameLayout.setBackgroundColor(color);
this.binding.fab.setImageResource(adBlocked ? R.drawable.ic_pause_24dp : R.drawable.logo);
}
private void notifyError(HostError error) {
removeView(this.binding.content.stateTextView);
if (error == null) {
return;
}
String message = getString(error.getDetailsKey()) + "\n\n" + getString(R.string.error_dialog_help);
new MaterialAlertDialogBuilder(this)
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(error.getMessageKey())
.setMessage(message)
.setPositiveButton(R.string.button_close, (dialog, id) -> dialog.dismiss())
.setNegativeButton(R.string.button_help, (dialog, id) -> {
dialog.dismiss();
startActivity(new Intent(this, HelpActivity.class));
})
.create()
.show();
}
private void showUpdate(View view) {
Intent intent = new Intent(this, UpdateActivity.class);
startActivity(intent);
}
}

View file

@ -0,0 +1,182 @@
package org.adaway.ui.home;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.MutableLiveData;
import org.adaway.AdAwayApplication;
import org.adaway.db.AppDatabase;
import org.adaway.db.dao.HostListItemDao;
import org.adaway.db.dao.HostsSourceDao;
import org.adaway.model.adblocking.AdBlockModel;
import org.adaway.model.error.HostError;
import org.adaway.model.error.HostErrorException;
import org.adaway.model.source.SourceModel;
import org.adaway.model.update.Manifest;
import org.adaway.model.update.UpdateModel;
import org.adaway.util.AppExecutors;
import timber.log.Timber;
/**
* This class is an {@link AndroidViewModel} for the {@link HomeActivity} cards.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class HomeViewModel extends AndroidViewModel {
private static final AppExecutors EXECUTORS = AppExecutors.getInstance();
private final SourceModel sourceModel;
private final AdBlockModel adBlockModel;
private final UpdateModel updateModel;
private final HostsSourceDao hostsSourceDao;
private final HostListItemDao hostListItemDao;
private final MutableLiveData<Boolean> pending;
private final MediatorLiveData<String> state;
private final MutableLiveData<HostError> error;
public HomeViewModel(@NonNull Application application) {
super(application);
AdAwayApplication awayApplication = (AdAwayApplication) application;
this.sourceModel = awayApplication.getSourceModel();
this.adBlockModel = awayApplication.getAdBlockModel();
this.updateModel = awayApplication.getUpdateModel();
AppDatabase database = AppDatabase.getInstance(application);
this.hostsSourceDao = database.hostsSourceDao();
this.hostListItemDao = database.hostsListItemDao();
this.pending = new MutableLiveData<>(false);
this.state = new MediatorLiveData<>();
this.state.addSource(this.sourceModel.getState(), this.state::setValue);
this.state.addSource(this.adBlockModel.getState(), this.state::setValue);
this.error = new MutableLiveData<>();
}
private static boolean isTrue(LiveData<Boolean> liveData) {
Boolean value = liveData.getValue();
return value != null && value;
}
public LiveData<Boolean> isAdBlocked() {
return this.adBlockModel.isApplied();
}
public LiveData<Boolean> isUpdateAvailable() {
return this.sourceModel.isUpdateAvailable();
}
public String getVersionName() {
return this.updateModel.getVersionName();
}
public LiveData<Manifest> getAppManifest() {
return this.updateModel.getManifest();
}
public LiveData<Integer> getBlockedHostCount() {
return this.hostListItemDao.getBlockedHostCount();
}
public LiveData<Integer> getAllowedHostCount() {
return this.hostListItemDao.getAllowedHostCount();
}
public LiveData<Integer> getRedirectHostCount() {
return this.hostListItemDao.getRedirectHostCount();
}
public LiveData<Integer> getUpToDateSourceCount() {
return this.hostsSourceDao.countUpToDate();
}
public LiveData<Integer> getOutdatedSourceCount() {
return this.hostsSourceDao.countOutdated();
}
public LiveData<Boolean> getPending() {
return this.pending;
}
public LiveData<String> getState() {
return this.state;
}
public LiveData<HostError> getError() {
return this.error;
}
public void checkForAppUpdate() {
EXECUTORS.networkIO().execute(this.updateModel::checkForUpdate);
}
public void toggleAdBlocking() {
if (isTrue(this.pending)) {
return;
}
EXECUTORS.diskIO().execute(() -> {
try {
this.pending.postValue(true);
if (isTrue(this.adBlockModel.isApplied())) {
this.adBlockModel.revert();
} else {
this.adBlockModel.apply();
}
} catch (HostErrorException exception) {
Timber.w(exception, "Failed to toggle ad blocking.");
this.error.postValue(exception.getError());
} finally {
this.pending.postValue(false);
}
});
}
public void update() {
if (isTrue(this.pending)) {
return;
}
EXECUTORS.networkIO().execute(() -> {
try {
this.pending.postValue(true);
this.sourceModel.checkForUpdate();
} catch (HostErrorException exception) {
Timber.w(exception, "Failed to update.");
this.error.postValue(exception.getError());
} finally {
this.pending.postValue(false);
}
});
}
public void sync() {
if (isTrue(this.pending)) {
return;
}
EXECUTORS.networkIO().execute(() -> {
try {
this.pending.postValue(true);
this.sourceModel.retrieveHostsSources();
this.adBlockModel.apply();
} catch (HostErrorException exception) {
Timber.w(exception, "Failed to sync.");
this.error.postValue(exception.getError());
} finally {
this.pending.postValue(false);
}
});
}
public void enableAllSources() {
EXECUTORS.diskIO().execute(() -> {
if (this.sourceModel.enableAllSources()) {
sync();
}
});
}
}

View file

@ -0,0 +1,41 @@
package org.adaway.ui.hosts;
import android.os.Bundle;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import org.adaway.R;
import org.adaway.helper.ThemeHelper;
/**
* This activity display hosts list items.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class HostsSourcesActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeHelper.applyTheme(this);
/*
* Create fragment
*/
HostsSourcesFragment fragment = new HostsSourcesFragment();
/*
* Set view content.
*/
setContentView(R.layout.hosts_sources_activity);
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.hosts_sources_container, fragment)
.commit();
/*
* Configure actionbar.
*/
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
}
}

View file

@ -0,0 +1,208 @@
package org.adaway.ui.hosts;
import android.content.Context;
import android.content.res.Resources;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.adaway.R;
import org.adaway.db.entity.HostsSource;
import java.time.Duration;
import java.time.ZonedDateTime;
/**
* This class is a the {@link RecyclerView.Adapter} for the hosts sources view.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
class HostsSourcesAdapter extends ListAdapter<HostsSource, HostsSourcesAdapter.ViewHolder> {
/**
* This callback is use to compare hosts sources.
*/
private static final DiffUtil.ItemCallback<HostsSource> DIFF_CALLBACK =
new DiffUtil.ItemCallback<HostsSource>() {
@Override
public boolean areItemsTheSame(@NonNull HostsSource oldSource, @NonNull HostsSource newSource) {
return oldSource.getUrl().equals(newSource.getUrl());
}
@Override
public boolean areContentsTheSame(@NonNull HostsSource oldSource, @NonNull HostsSource newSource) {
// NOTE: if you use equals, your object must properly override Object#equals()
// Incorrectly returning false here will result in too many animations.
return oldSource.equals(newSource);
}
};
/**
* This callback is use to call view actions.
*/
@NonNull
private final HostsSourcesViewCallback viewCallback;
private static final String[] QUANTITY_PREFIXES = new String[]{"k", "M", "G"};
/**
* Constructor.
*
* @param viewCallback The view callback.
*/
HostsSourcesAdapter(@NonNull HostsSourcesViewCallback viewCallback) {
super(DIFF_CALLBACK);
this.viewCallback = viewCallback;
}
/**
* Get the approximate delay from a date to now.
*
* @param context The application context.
* @param from The date from which computes the delay.
* @return The approximate delay.
*/
private static String getApproximateDelay(Context context, ZonedDateTime from) {
// Get resource for plurals
Resources resources = context.getResources();
// Get current date in UTC timezone
ZonedDateTime now = ZonedDateTime.now();
// Get delay between from and now in minutes
long delay = Duration.between(from, now).toMinutes();
// Check if delay is lower than an hour
if (delay < 60) {
return resources.getString(R.string.hosts_source_few_minutes);
}
// Get delay in hours
delay /= 60;
// Check if delay is lower than a day
if (delay < 24) {
int hours = (int) delay;
return resources.getQuantityString(R.plurals.hosts_source_hours, hours, hours);
}
// Get delay in days
delay /= 24;
// Check if delay is lower than a month
if (delay < 30) {
int days = (int) delay;
return resources.getQuantityString(R.plurals.hosts_source_days, days, days);
}
// Get delay in months
int months = (int) delay / 30;
return resources.getQuantityString(R.plurals.hosts_source_months, months, months);
}
@NonNull
@Override
public HostsSourcesAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
View view = layoutInflater.inflate(R.layout.hosts_sources_card, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
HostsSource source = this.getItem(position);
holder.enabledCheckBox.setChecked(source.isEnabled());
holder.enabledCheckBox.setOnClickListener(view -> viewCallback.toggleEnabled(source));
holder.labelTextView.setText(source.getLabel());
holder.urlTextView.setText(source.getUrl());
holder.updateTextView.setText(getUpdateText(source));
holder.sizeTextView.setText(getHostCount(source));
holder.itemView.setOnClickListener(view -> viewCallback.edit(source));
}
private String getUpdateText(HostsSource source) {
// Get context
Context context = this.viewCallback.getContext();
// Check if source is enabled
if (!source.isEnabled()) {
return context.getString(R.string.hosts_source_disabled);
}
// Check modification dates
boolean lastOnlineModificationDefined = source.getOnlineModificationDate() != null;
boolean lastLocalModificationDefined = source.getLocalModificationDate() != null;
// Declare update text
String updateText;
// Check if last online modification date is known
if (lastOnlineModificationDefined) {
// Get last online modification delay
String approximateDelay = getApproximateDelay(context, source.getOnlineModificationDate());
if (!lastLocalModificationDefined) {
updateText = context.getString(R.string.hosts_source_last_update, approximateDelay);
} else if (source.getOnlineModificationDate().isAfter(source.getLocalModificationDate())) {
updateText = context.getString(R.string.hosts_source_need_update, approximateDelay);
} else {
updateText = context.getString(R.string.hosts_source_up_to_date, approximateDelay);
}
} else {
if (lastLocalModificationDefined) {
String approximateDelay = getApproximateDelay(context, source.getLocalModificationDate());
updateText = context.getString(R.string.hosts_source_installed, approximateDelay);
} else {
updateText = context.getString(R.string.hosts_source_unknown_status);
}
}
return updateText;
}
private String getHostCount(HostsSource source) {
// Note: NumberFormat.getCompactNumberInstance is Java 12 only
// Check empty source
int size = source.getSize();
if (size <= 0 || !source.isEnabled()) {
return "";
}
// Compute size decimal length
int length = 1;
while (size > 10) {
size /= 10;
length++;
}
// Compute prefix to use
int prefixIndex = (length - 1) / 3 - 1;
// Return formatted count
Context context = this.viewCallback.getContext();
size = source.getSize();
if (prefixIndex < 0) {
return context.getString(R.string.hosts_count, Integer.toString(size));
} else if (prefixIndex >= QUANTITY_PREFIXES.length) {
prefixIndex = QUANTITY_PREFIXES.length - 1;
size = 13;
}
size = Math.toIntExact(Math.round(size / Math.pow(10, (prefixIndex + 1) * 3D)));
return context.getString(R.string.hosts_count, size + QUANTITY_PREFIXES[prefixIndex]);
}
/**
* This class is a the {@link RecyclerView.ViewHolder} for the hosts sources view.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
static class ViewHolder extends RecyclerView.ViewHolder {
final CheckBox enabledCheckBox;
final TextView labelTextView;
final TextView urlTextView;
final TextView updateTextView;
final TextView sizeTextView;
/**
* Constructor.
*
* @param itemView The hosts sources view.
*/
ViewHolder(View itemView) {
super(itemView);
this.enabledCheckBox = itemView.findViewById(R.id.sourceEnabledCheckBox);
this.labelTextView = itemView.findViewById(R.id.sourceLabelTextView);
this.urlTextView = itemView.findViewById(R.id.sourceUrlTextView);
this.updateTextView = itemView.findViewById(R.id.sourceUpdateTextView);
this.sizeTextView = itemView.findViewById(R.id.sourceSizeTextView);
}
}
}

View file

@ -0,0 +1,119 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.hosts;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import org.adaway.R;
import org.adaway.db.entity.HostsSource;
import org.adaway.ui.adblocking.ApplyConfigurationSnackbar;
import org.adaway.ui.source.SourceEditActivity;
import static org.adaway.ui.source.SourceEditActivity.SOURCE_ID;
/**
* This class is a {@link Fragment} to display and manage hosts sources.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class HostsSourcesFragment extends Fragment implements HostsSourcesViewCallback {
/**
* The view model (<code>null</code> if view is not created).
*/
private HostsSourcesViewModel mViewModel;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
// Get activity
Activity activity = requireActivity();
// Initialize view model
this.mViewModel = new ViewModelProvider(this).get(HostsSourcesViewModel.class);
LifecycleOwner lifecycleOwner = getViewLifecycleOwner();
// Create fragment view
View view = inflater.inflate(R.layout.hosts_sources_fragment, container, false);
/*
* Configure snackbar.
*/
// Get lists layout to attached snackbar to
CoordinatorLayout coordinatorLayout = view.findViewById(R.id.coordinator);
// Create apply snackbar
ApplyConfigurationSnackbar applySnackbar = new ApplyConfigurationSnackbar(coordinatorLayout, true, true);
// Bind snakbar to view models
this.mViewModel.getHostsSources().observe(lifecycleOwner, applySnackbar.createObserver());
/*
* Configure recycler view.
*/
// Store recycler view
RecyclerView recyclerView = view.findViewById(R.id.hosts_sources_list);
recyclerView.setHasFixedSize(true);
// Defile recycler layout
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(activity);
recyclerView.setLayoutManager(linearLayoutManager);
// Create recycler adapter
HostsSourcesAdapter adapter = new HostsSourcesAdapter(this);
recyclerView.setAdapter(adapter);
// Bind adapter to view model
this.mViewModel.getHostsSources().observe(lifecycleOwner, adapter::submitList);
/*
* Add floating action button.
*/
// Get floating action button
FloatingActionButton button = view.findViewById(R.id.hosts_sources_add);
// Set click listener to display menu add entry
button.setOnClickListener(actionButton -> startSourceEdition(null));
// Return fragment view
return view;
}
@Override
public void toggleEnabled(HostsSource source) {
this.mViewModel.toggleSourceEnabled(source);
}
@Override
public void edit(HostsSource source) {
startSourceEdition(source);
}
private void startSourceEdition(@Nullable HostsSource source) {
Intent intent = new Intent(requireContext(), SourceEditActivity.class);
if (source != null) {
intent.putExtra(SOURCE_ID, source.getId());
}
startActivity(intent);
}
}

View file

@ -0,0 +1,33 @@
package org.adaway.ui.hosts;
import android.content.Context;
import org.adaway.db.entity.HostsSource;
/**
* This class is represents the {@link HostsSourcesFragment} callback.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public interface HostsSourcesViewCallback {
/**
* Get the application context.
*
* @return The application context.
*/
Context getContext();
/**
* Toggle host source enable status.
*
* @param source The hosts source to toggle status.
*/
void toggleEnabled(HostsSource source);
/**
* Start an action.
*
* @param source The hosts source to start the action.
*/
void edit(HostsSource source);
}

View file

@ -0,0 +1,38 @@
package org.adaway.ui.hosts;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import org.adaway.db.AppDatabase;
import org.adaway.db.dao.HostsSourceDao;
import org.adaway.db.entity.HostsSource;
import org.adaway.util.AppExecutors;
import java.util.List;
import java.util.concurrent.Executor;
/**
* This class is an {@link AndroidViewModel} for the {@link HostsSourcesFragment}.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class HostsSourcesViewModel extends AndroidViewModel {
private static final Executor EXECUTOR = AppExecutors.getInstance().diskIO();
private final HostsSourceDao hostsSourceDao;
public HostsSourcesViewModel(@NonNull Application application) {
super(application);
this.hostsSourceDao = AppDatabase.getInstance(application).hostsSourceDao();
}
public LiveData<List<HostsSource>> getHostsSources() {
return this.hostsSourceDao.loadAll();
}
public void toggleSourceEnabled(HostsSource source) {
EXECUTOR.execute(() -> this.hostsSourceDao.toggleEnabled(source));
}
}

View file

@ -0,0 +1,185 @@
package org.adaway.ui.lists;
import static android.content.Intent.ACTION_SEARCH;
import android.app.SearchManager;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.SearchView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.lifecycle.ViewModelProvider;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import org.adaway.R;
import org.adaway.helper.ThemeHelper;
import org.adaway.ui.adblocking.ApplyConfigurationSnackbar;
/**
* This activity display hosts list items.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class ListsActivity extends AppCompatActivity {
/**
* The tab to display argument.
*/
public static final String TAB = "org.adaway.lists.tab";
/**
* The blocked hosts tab index.
*/
public static final int BLOCKED_HOSTS_TAB = 0;
/**
* The allowed hosts tab index.
*/
public static final int ALLOWED_HOSTS_TAB = 1;
/**
* The redirected hosts tab index.
*/
public static final int REDIRECTED_HOSTS_TAB = 2;
/**
* The view model.
*/
private ListsViewModel listsViewModel;
/**
* The back press callback.
*/
private OnBackPressedCallback onBackPressedCallback;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeHelper.applyTheme(this);
/*
* Set view content.
*/
setContentView(R.layout.lists_fragment);
/*
* Configure actionbar.
*/
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
/*
* Configure back press callback.
*/
this.onBackPressedCallback = new OnBackPressedCallback(false) {
@Override
public void handleOnBackPressed() {
ListsActivity.this.listsViewModel.clearSearch();
ListsActivity.this.onBackPressedCallback.setEnabled(false);
}
};
getOnBackPressedDispatcher().addCallback(this.onBackPressedCallback);
/*
* Configure tabs.
*/
// Get view pager
ViewPager2 viewPager = findViewById(R.id.lists_view_pager);
// Create pager adapter
ListsFragmentPagerAdapter pagerAdapter = new ListsFragmentPagerAdapter(this);
// Set view pager adapter
viewPager.setAdapter(pagerAdapter);
// Get navigation view
BottomNavigationView navigationView = findViewById(R.id.navigation);
// Add view pager on page listener to set selected tab according the selected page
viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
navigationView.getMenu().getItem(position).setChecked(true);
pagerAdapter.ensureActionModeCanceled();
}
});
// Add navigation view item selected listener to change view pager current item
navigationView.setOnItemSelectedListener(item -> {
if (item.getItemId() == R.id.lists_navigation_blocked) {
viewPager.setCurrentItem(0);
return true;
} else if (item.getItemId() == R.id.lists_navigation_allowed) {
viewPager.setCurrentItem(1);
return true;
} else if (item.getItemId() == R.id.lists_navigation_redirected) {
viewPager.setCurrentItem(2);
return true;
}
return false;
});
// Display requested tab
Intent intent = getIntent();
int tab = intent.getIntExtra(TAB, BLOCKED_HOSTS_TAB);
viewPager.setCurrentItem(tab);
/*
* Configure add action button.
*/
// Get the add action button
FloatingActionButton addActionButton = findViewById(R.id.lists_add);
// Set add action button listener
addActionButton.setOnClickListener(clickedView -> {
// Get current fragment position
int currentItemPosition = viewPager.getCurrentItem();
// Add item to the current fragment
pagerAdapter.addItem(currentItemPosition);
});
/*
* Configure snackbar.
*/
// Get lists layout to attached snackbar to
CoordinatorLayout coordinatorLayout = findViewById(R.id.coordinator);
// Create apply snackbar
ApplyConfigurationSnackbar applySnackbar = new ApplyConfigurationSnackbar(coordinatorLayout, false, false);
// Bind snackbar to view models
this.listsViewModel = new ViewModelProvider(this).get(ListsViewModel.class);
this.listsViewModel.getModelChanged().observe(this, applySnackbar.createObserver());
// Get the intent, verify the action and get the query
handleQuery(intent);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleQuery(intent);
}
private void handleQuery(Intent intent) {
if (ACTION_SEARCH.equals(intent.getAction())) {
String query = intent.getStringExtra(SearchManager.QUERY);
this.listsViewModel.search(query);
this.onBackPressedCallback.setEnabled(true);
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.list_menu, menu);
// Get the SearchView and set the searchable configuration
SearchManager searchManager = (SearchManager) getSystemService(SEARCH_SERVICE);
if (searchManager != null) {
SearchView searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView();
searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName()));
searchView.setIconifiedByDefault(false);
}
return true;
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.menu_toggle_source) {
this.listsViewModel.toggleSources();
return true;
}
return super.onOptionsItemSelected(item);
}
}

View file

@ -0,0 +1,33 @@
package org.adaway.ui.lists;
/**
* This class represents the filter to apply to host lists.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class ListsFilter {
public static final ListsFilter ALL = new ListsFilter(true, "");
/**
* Whether included hosts from sources or not.
*/
public final boolean sourcesIncluded;
/**
* The query filter to apply to hosts name (wildcard based).
*/
public final String query;
/**
* The query filter to apply to hosts name (sql like format).
*/
public final String sqlQuery;
public ListsFilter(boolean sourcesIncluded, String query) {
this.sourcesIncluded = sourcesIncluded;
this.query = query;
this.sqlQuery = convertToLikeQuery(query);
}
private static String convertToLikeQuery(String query) {
return "%" + query.replace("*", "%")
.replace("?", "_") + "%";
}
}

View file

@ -0,0 +1,110 @@
package org.adaway.ui.lists;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import org.adaway.ui.lists.type.AbstractListFragment;
import org.adaway.ui.lists.type.AllowedHostsFragment;
import org.adaway.ui.lists.type.BlockedHostsFragment;
import org.adaway.ui.lists.type.RedirectedHostsFragment;
import static org.adaway.ui.lists.ListsActivity.BLOCKED_HOSTS_TAB;
import static org.adaway.ui.lists.ListsActivity.REDIRECTED_HOSTS_TAB;
import static org.adaway.ui.lists.ListsActivity.ALLOWED_HOSTS_TAB;
/**
* This class is a {@link FragmentStateAdapter} to store lists tab fragments.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
class ListsFragmentPagerAdapter extends FragmentStateAdapter {
/**
* The number of fragment.
*/
private static final int FRAGMENT_COUNT = 3;
/**
* The blacklist fragment (<code>null</code> until first retrieval).
*/
private final AbstractListFragment blacklistFragment;
/**
* The whitelist fragment (<code>null</code> until first retrieval).
*/
private final AbstractListFragment whitelistFragment;
/**
* The redirection list fragment (<code>null</code> until first retrieval).
*/
private final AbstractListFragment redirectionListFragment;
/**
* Constructor.
*
*/
ListsFragmentPagerAdapter(FragmentActivity fragmentActivity) {
super(fragmentActivity);
this.blacklistFragment = new BlockedHostsFragment();
this.whitelistFragment = new AllowedHostsFragment();
this.redirectionListFragment = new RedirectedHostsFragment();
}
@NonNull
@Override
public Fragment createFragment(int position) {
switch (position) {
case BLOCKED_HOSTS_TAB:
return this.blacklistFragment;
case ALLOWED_HOSTS_TAB:
return this.whitelistFragment;
case REDIRECTED_HOSTS_TAB:
return this.redirectionListFragment;
default:
throw new IllegalStateException("Position " + position + " is not supported.");
}
}
@Override
public int getItemCount() {
return FRAGMENT_COUNT;
}
/**
* Ensure action mode is cancelled.
*/
void ensureActionModeCanceled() {
if (this.blacklistFragment != null) {
this.blacklistFragment.ensureActionModeCanceled();
}
if (this.whitelistFragment != null) {
this.whitelistFragment.ensureActionModeCanceled();
}
if (this.redirectionListFragment != null) {
this.redirectionListFragment.ensureActionModeCanceled();
}
}
/**
* Add an item into the requested fragment.
*
* @param position The fragment position.
*/
void addItem(int position) {
switch (position) {
case BLOCKED_HOSTS_TAB:
if (this.blacklistFragment != null) {
this.blacklistFragment.addItem();
}
break;
case ALLOWED_HOSTS_TAB:
if (this.whitelistFragment != null) {
this.whitelistFragment.addItem();
}
break;
case REDIRECTED_HOSTS_TAB:
if (this.redirectionListFragment != null) {
this.redirectionListFragment.addItem();
}
break;
}
}
}

View file

@ -0,0 +1,36 @@
package org.adaway.ui.lists;
import android.view.View;
import org.adaway.db.entity.HostListItem;
import org.adaway.ui.lists.type.AbstractListFragment;
/**
* This class is represents the {@link AbstractListFragment} callback.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public interface ListsViewCallback {
/**
* Toggle item enable status.
*
* @param item The list to toggle status.
*/
void toggleItemEnabled(HostListItem item);
/**
* Start an action.
*
* @param item The list to start the action.
* @param sourceView The list related view.
* @return <code>true</code> if the action was started, <code>false</code> otherwise.
*/
boolean startAction(HostListItem item, View sourceView);
/**
* Copy an hosts into clipboard.
*
* @param item The list to copy hosts.
*/
boolean copyHostToClipboard(HostListItem item);
}

View file

@ -0,0 +1,160 @@
package org.adaway.ui.lists;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.paging.Pager;
import androidx.paging.PagingConfig;
import androidx.paging.PagingData;
import org.adaway.db.AppDatabase;
import org.adaway.db.dao.HostListItemDao;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.ListType;
import org.adaway.ui.lists.type.AbstractListFragment;
import org.adaway.util.AppExecutors;
import java.util.Optional;
import java.util.concurrent.Executor;
import static androidx.lifecycle.Transformations.switchMap;
import static androidx.paging.PagingLiveData.getLiveData;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
import static org.adaway.db.entity.ListType.ALLOWED;
import static org.adaway.db.entity.ListType.BLOCKED;
import static org.adaway.db.entity.ListType.REDIRECTED;
import static org.adaway.ui.lists.ListsFilter.ALL;
/**
* This class is an {@link AndroidViewModel} for the {@link AbstractListFragment} implementations.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class ListsViewModel extends AndroidViewModel {
private static final Executor EXECUTOR = AppExecutors.getInstance().diskIO();
private final HostListItemDao hostListItemDao;
private final MutableLiveData<ListsFilter> filter;
private final LiveData<PagingData<HostListItem>> blockedListItems;
private final LiveData<PagingData<HostListItem>> allowedListItems;
private final LiveData<PagingData<HostListItem>> redirectedListItems;
private final MutableLiveData<Boolean> modelChanged;
public ListsViewModel(@NonNull Application application) {
super(application);
this.hostListItemDao = AppDatabase.getInstance(application).hostsListItemDao();
this.filter = new MutableLiveData<>(ALL);
PagingConfig pagingConfig = new PagingConfig(50, 150, true);
this.blockedListItems = switchMap(
this.filter,
filter -> getLiveData(new Pager<>(pagingConfig, () ->
this.hostListItemDao.loadList(BLOCKED.getValue(), filter.sourcesIncluded, filter.sqlQuery)
))
);
this.allowedListItems = switchMap(
this.filter,
filter -> getLiveData(new Pager<>(pagingConfig, () ->
this.hostListItemDao.loadList(ALLOWED.getValue(), filter.sourcesIncluded, filter.sqlQuery)
))
);
this.redirectedListItems = switchMap(
this.filter,
filter -> getLiveData(new Pager<>(pagingConfig, () ->
this.hostListItemDao.loadList(REDIRECTED.getValue(), filter.sourcesIncluded, filter.sqlQuery)
))
);
this.modelChanged = new MutableLiveData<>(false);
}
public LiveData<PagingData<HostListItem>> getBlockedListItems() {
return this.blockedListItems;
}
public LiveData<PagingData<HostListItem>> getAllowedListItems() {
return this.allowedListItems;
}
public LiveData<PagingData<HostListItem>> getRedirectedListItems() {
return this.redirectedListItems;
}
public LiveData<Boolean> getModelChanged() {
return this.modelChanged;
}
public void toggleItemEnabled(HostListItem item) {
item.setEnabled(!item.isEnabled());
EXECUTOR.execute(() -> {
this.hostListItemDao.update(item);
this.modelChanged.postValue(true);
});
}
public void addListItem(@NonNull ListType type, @NonNull String host, String redirection) {
HostListItem item = new HostListItem();
item.setType(type);
item.setHost(host);
item.setRedirection(redirection);
item.setEnabled(true);
item.setSourceId(USER_SOURCE_ID);
EXECUTOR.execute(() -> {
Optional<Integer> id = this.hostListItemDao.getHostId(host);
if (id.isPresent()) {
item.setId(id.get());
this.hostListItemDao.update(item);
} else {
this.hostListItemDao.insert(item);
}
this.modelChanged.postValue(true);
});
}
public void updateListItem(@NonNull HostListItem item, @NonNull String host, String redirection) {
item.setHost(host);
item.setRedirection(redirection);
EXECUTOR.execute(() -> {
this.hostListItemDao.update(item);
this.modelChanged.postValue(true);
});
}
public void removeListItem(HostListItem list) {
EXECUTOR.execute(() -> {
this.hostListItemDao.delete(list);
this.modelChanged.postValue(true);
});
}
public void search(String query) {
ListsFilter currentFilter = getFilter();
ListsFilter newFilter = new ListsFilter(currentFilter.sourcesIncluded, query);
setFilter(newFilter);
}
public boolean isSearching() {
return !getFilter().query.isEmpty();
}
public void clearSearch() {
ListsFilter currentFilter = getFilter();
ListsFilter newFilter = new ListsFilter(currentFilter.sourcesIncluded, "");
setFilter(newFilter);
}
public void toggleSources() {
ListsFilter currentFilter = getFilter();
ListsFilter newFilter = new ListsFilter(!currentFilter.sourcesIncluded, currentFilter.query);
setFilter(newFilter);
}
private ListsFilter getFilter() {
ListsFilter filter = this.filter.getValue();
return filter == null ? ALL : filter;
}
private void setFilter(ListsFilter filter) {
this.filter.setValue(filter);
}
}

View file

@ -0,0 +1,203 @@
package org.adaway.ui.lists.type;
import android.graphics.Color;
import android.os.Bundle;
import android.view.ActionMode;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.ViewModelProvider;
import androidx.paging.PagingData;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.adaway.R;
import org.adaway.db.entity.HostListItem;
import org.adaway.ui.lists.ListsViewCallback;
import org.adaway.ui.lists.ListsViewModel;
import org.adaway.util.Clipboard;
/**
* This class is a {@link Fragment} to display and manage lists of {@link org.adaway.ui.lists.ListsActivity}.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public abstract class AbstractListFragment extends Fragment implements ListsViewCallback {
/**
* The view model (<code>null</code> if view is not created).
*/
protected ListsViewModel mViewModel;
/**
* The current activity (<code>null</code> if view is not created).
*/
protected FragmentActivity mActivity;
/**
* The current action mode when item is selection (<code>null</code> if no action started).
*/
private ActionMode mActionMode;
/**
* The action mode callback (<code>null</code> if view is not created).
*/
private ActionMode.Callback mActionCallback;
/**
* The hosts list related to the current action (<code>null</code> if view is not created).
*/
private HostListItem mActionItem;
/**
* The view related hosts source of the current action (<code>null</code> if view is not created).
*/
private View mActionSourceView;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
// Store activity
this.mActivity = requireActivity();
// Create fragment view
View view = inflater.inflate(R.layout.hosts_lists_fragment, container, false);
/*
* Configure recycler view.
*/
// Store recycler view
RecyclerView recyclerView = view.findViewById(R.id.hosts_lists_list);
recyclerView.setHasFixedSize(true);
// Defile recycler layout
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this.mActivity);
recyclerView.setLayoutManager(linearLayoutManager);
// Create recycler adapter
ListsAdapter adapter = new ListsAdapter(this, isTwoRowsItem());
recyclerView.setAdapter(adapter);
/*
* Create action mode.
*/
// Create action mode callback to display edit/delete menu
this.mActionCallback = new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
// Get menu inflater
MenuInflater inflater = actionMode.getMenuInflater();
// Set action mode title
actionMode.setTitle(R.string.checkbox_list_context_title);
// Inflate edit/delete menu
inflater.inflate(R.menu.checkbox_list_context, menu);
// Return action created
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
// Nothing special to do
return false;
}
@Override
public boolean onActionItemClicked(ActionMode actionMode, MenuItem item) {
// Check action item
if (mActionItem == null) {
return false;
}
// Check item identifier
if (item.getItemId() == R.id.edit_action) {
// Edit action item
editItem(mActionItem);
// Finish action mode
mActionMode.finish();
return true;
} else if (item.getItemId() == R.id.delete_action) {
// Delete action item
deleteItem(mActionItem);
// Finish action mode
mActionMode.finish();
return true;
} else {
return false;
}
}
@Override
public void onDestroyActionMode(ActionMode actionMode) {
// Clear view background color
if (mActionSourceView != null) {
mActionSourceView.setBackgroundColor(Color.TRANSPARENT);
}
// Clear current source and its view
mActionItem = null;
mActionSourceView = null;
// Clear action mode
mActionMode = null;
}
};
/*
* Load data.
*/
// Get view model and bind it to the list view
this.mViewModel = new ViewModelProvider(this.mActivity).get(ListsViewModel.class);
getData().observe(getViewLifecycleOwner(), data -> adapter.submitData(getLifecycle(), data));
// Return created view
return view;
}
@Override
public boolean startAction(HostListItem item, View sourceView) {
// Check if there is already a current action
if (this.mActionMode != null) {
return false;
}
// Store current source and its view
this.mActionItem = item;
this.mActionSourceView = sourceView;
// Get current item background color
int currentItemBackgroundColor = getResources().getColor(R.color.selected_background, null);
// Apply background color to view
this.mActionSourceView.setBackgroundColor(currentItemBackgroundColor);
// Start action mode and store it
this.mActionMode = this.mActivity.startActionMode(this.mActionCallback);
// Return event consumed
return true;
}
@Override
public boolean copyHostToClipboard(HostListItem item) {
Clipboard.copyHostToClipboard(this.mActivity, item.getHost());
return true;
}
/**
* Ensure action mode is cancelled.
*/
public void ensureActionModeCanceled() {
if (this.mActionMode != null) {
this.mActionMode.finish();
}
}
protected abstract LiveData<PagingData<HostListItem>> getData();
protected boolean isTwoRowsItem() {
return false;
}
/**
* Display a UI to add an item to the list.
*/
public abstract void addItem();
protected abstract void editItem(HostListItem item);
protected void deleteItem(HostListItem item) {
this.mViewModel.removeListItem(item);
}
@Override
public void toggleItemEnabled(HostListItem item) {
this.mViewModel.toggleItemEnabled(item);
}
}

View file

@ -0,0 +1,132 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.lists.type;
import android.text.Editable;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.LiveData;
import androidx.paging.PagingData;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.adaway.R;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.ListType;
import org.adaway.ui.dialog.AlertDialogValidator;
import org.adaway.util.RegexUtils;
/**
* This class is a {@link AbstractListFragment} to display and manage allowed hosts.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class AllowedHostsFragment extends AbstractListFragment {
@Override
protected LiveData<PagingData<HostListItem>> getData() {
return this.mViewModel.getAllowedListItems();
}
@Override
public void addItem() {
// Create dialog view
LayoutInflater factory = LayoutInflater.from(this.mActivity);
View view = factory.inflate(R.layout.lists_allowed_dialog, null);
EditText inputEditText = view.findViewById(R.id.list_dialog_hostname);
// Create dialog
AlertDialog alertDialog = new MaterialAlertDialogBuilder(this.mActivity)
.setCancelable(true)
.setTitle(R.string.list_add_dialog_white)
.setView(view)
// Setup buttons
.setPositiveButton(
R.string.button_add,
(dialog, which) -> {
// Close dialog
dialog.dismiss();
// Check hostname validity
String hostname = inputEditText.getText().toString();
if (RegexUtils.isValidWildcardHostname(hostname)) {
// Insert host to whitelist
this.mViewModel.addListItem(ListType.ALLOWED, hostname, null);
}
}
)
.setNegativeButton(
R.string.button_cancel,
(dialog, which) -> dialog.dismiss()
)
.create();
// Show dialog
alertDialog.show();
// Set button validation behavior
inputEditText.addTextChangedListener(
new AlertDialogValidator(alertDialog, RegexUtils::isValidWildcardHostname, false)
);
}
@Override
protected void editItem(HostListItem item) {
// Create dialog view
LayoutInflater factory = LayoutInflater.from(this.mActivity);
View view = factory.inflate(R.layout.lists_allowed_dialog, null);
// Set hostname
EditText inputEditText = view.findViewById(R.id.list_dialog_hostname);
inputEditText.setText(item.getHost());
// Move cursor to end of EditText
Editable inputEditContent = inputEditText.getText();
inputEditText.setSelection(inputEditContent.length());
// Create dialog builder
AlertDialog alertDialog = new MaterialAlertDialogBuilder(this.mActivity)
.setCancelable(true)
.setTitle(R.string.list_edit_dialog_white)
.setView(view)
// Setup buttons
.setPositiveButton(
R.string.button_save,
(dialog, which) -> {
// Close dialog
dialog.dismiss();
// Check hostname validity
String hostname = inputEditText.getText().toString();
if (RegexUtils.isValidWildcardHostname(hostname)) {
// Update list item
this.mViewModel.updateListItem(item, hostname, null);
}
}
)
.setNegativeButton(
R.string.button_cancel,
(dialog, which) -> dialog.dismiss()
)
.create();
// Show dialog
alertDialog.show();
// Set button validation behavior
inputEditText.addTextChangedListener(
new AlertDialogValidator(alertDialog, RegexUtils::isValidWildcardHostname, true)
);
}
}

View file

@ -0,0 +1,131 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.lists.type;
import android.text.Editable;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.LiveData;
import androidx.paging.PagingData;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.adaway.R;
import org.adaway.db.entity.HostListItem;
import org.adaway.ui.dialog.AlertDialogValidator;
import org.adaway.util.RegexUtils;
import static org.adaway.db.entity.ListType.BLOCKED;
/**
* This class is a {@link AbstractListFragment} to display and manage blocked hosts.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class BlockedHostsFragment extends AbstractListFragment {
@Override
protected LiveData<PagingData<HostListItem>> getData() {
return this.mViewModel.getBlockedListItems();
}
@Override
public void addItem() {
// Create dialog view
LayoutInflater factory = LayoutInflater.from(this.mActivity);
View view = factory.inflate(R.layout.lists_blocked_dialog, null);
EditText inputEditText = view.findViewById(R.id.list_dialog_hostname);
// Create dialog
AlertDialog alertDialog = new MaterialAlertDialogBuilder(this.mActivity)
.setCancelable(true)
.setTitle(R.string.list_add_dialog_black)
.setView(view)
// Setup buttons
.setPositiveButton(
R.string.button_add,
(dialog, which) -> {
// Close dialog
dialog.dismiss();
// Check if hostname is valid
String hostname = inputEditText.getText().toString();
if (RegexUtils.isValidHostname(hostname)) {
// Insert host to black list
this.mViewModel.addListItem(BLOCKED, hostname, null);
}
})
.setNegativeButton(
R.string.button_cancel,
(dialog, which) -> dialog.dismiss()
)
.create();
// Show dialog
alertDialog.show();
// Set button validation behavior
inputEditText.addTextChangedListener(
new AlertDialogValidator(alertDialog, RegexUtils::isValidHostname, false)
);
}
@Override
protected void editItem(HostListItem item) {
// Create dialog view
LayoutInflater factory = LayoutInflater.from(this.mActivity);
View view = factory.inflate(R.layout.lists_blocked_dialog, null);
// Set hostname
EditText inputEditText = view.findViewById(R.id.list_dialog_hostname);
inputEditText.setText(item.getHost());
// Move cursor to end of EditText
Editable inputEditContent = inputEditText.getText();
inputEditText.setSelection(inputEditContent.length());
// Create dialog
AlertDialog alertDialog = new MaterialAlertDialogBuilder(this.mActivity)
.setCancelable(true)
.setTitle(R.string.list_edit_dialog_black)
.setView(view)
// Setup buttons
.setPositiveButton(
R.string.button_save,
(dialog, which) -> {
// Close dialog
dialog.dismiss();
// Check hostname validity
String hostname = inputEditText.getText().toString();
if (RegexUtils.isValidHostname(hostname)) {
// Update list item
this.mViewModel.updateListItem(item, hostname, null);
}
})
.setNegativeButton(
R.string.button_cancel
, (dialog, which) -> dialog.dismiss()
)
.create();
// Show dialog
alertDialog.show();
// Set button validation behavior
inputEditText.addTextChangedListener(
new AlertDialogValidator(alertDialog, RegexUtils::isValidHostname, true)
);
}
}

View file

@ -0,0 +1,131 @@
package org.adaway.ui.lists.type;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.paging.PagingDataAdapter;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import org.adaway.R;
import org.adaway.db.entity.HostListItem;
import org.adaway.ui.lists.ListsViewCallback;
import static org.adaway.db.entity.HostsSource.USER_SOURCE_ID;
/**
* This class is a the {@link RecyclerView.Adapter} for the hosts list view.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
class ListsAdapter extends PagingDataAdapter<HostListItem, ListsAdapter.ViewHolder> {
/**
* This callback is use to compare hosts sources.
*/
private static final DiffUtil.ItemCallback<HostListItem> DIFF_CALLBACK =
new DiffUtil.ItemCallback<HostListItem>() {
@Override
public boolean areItemsTheSame(@NonNull HostListItem oldItem, @NonNull HostListItem newItem) {
return (oldItem.getHost().equals(newItem.getHost()));
}
@Override
public boolean areContentsTheSame(@NonNull HostListItem oldItem, @NonNull HostListItem newItem) {
// NOTE: if you use equals, your object must properly override Object#equals()
// Incorrectly returning false here will result in too many animations.
return oldItem.equals(newItem);
}
};
/**
* This callback is use to call view actions.
*/
@NonNull
private final ListsViewCallback viewCallback;
/**
* Whether the list item needs two rows or not.
*/
private final boolean twoRows;
/**
* Constructor.
*
* @param viewCallback The view callback.
* @param twoRows Whether the list items need two rows or not.
*/
ListsAdapter(@NonNull ListsViewCallback viewCallback, boolean twoRows) {
super(DIFF_CALLBACK);
this.viewCallback = viewCallback;
this.twoRows = twoRows;
}
@NonNull
@Override
public ListsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
View view = layoutInflater.inflate(
this.twoRows ? R.layout.checkbox_list_two_entries : R.layout.checkbox_list_entry,
parent,
false
);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
HostListItem item = getItem(position);
if (item == null) { // Data might be null if not loaded yet
holder.clear();
return;
}
boolean editable = item.getSourceId() == USER_SOURCE_ID;
holder.enabledCheckBox.setEnabled(editable);
holder.enabledCheckBox.setChecked(item.isEnabled());
holder.enabledCheckBox.setOnClickListener(editable ? view -> this.viewCallback.toggleItemEnabled(item) : null);
holder.hostTextView.setText(item.getHost());
if (this.twoRows) {
holder.redirectionTextView.setText(item.getRedirection());
}
holder.itemView.setOnLongClickListener(editable ?
view -> this.viewCallback.startAction(item, holder.itemView) :
view -> this.viewCallback.copyHostToClipboard(item));
}
/**
* This class is a the {@link RecyclerView.ViewHolder} for the hosts list view.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
static class ViewHolder extends RecyclerView.ViewHolder {
final CheckBox enabledCheckBox;
final TextView hostTextView;
final TextView redirectionTextView;
/**
* Constructor.
*
* @param itemView The hosts sources view.
*/
ViewHolder(View itemView) {
super(itemView);
this.enabledCheckBox = itemView.findViewById(R.id.checkbox_list_checkbox);
this.hostTextView = itemView.findViewById(R.id.checkbox_list_text);
this.redirectionTextView = itemView.findViewById(R.id.checkbox_list_subtext);
}
void clear() {
this.enabledCheckBox.setChecked(true);
this.enabledCheckBox.setEnabled(false);
this.enabledCheckBox.setOnClickListener(null);
this.hostTextView.setText("");
if (this.redirectionTextView != null) {
this.redirectionTextView.setText("");
}
this.itemView.setOnLongClickListener(null);
}
}
}

View file

@ -0,0 +1,156 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.lists.type;
import android.text.Editable;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.LiveData;
import androidx.paging.PagingData;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.adaway.R;
import org.adaway.db.entity.HostListItem;
import org.adaway.db.entity.ListType;
import org.adaway.ui.dialog.AlertDialogValidator;
import org.adaway.util.RegexUtils;
/**
* This class is a {@link AbstractListFragment} to display and manage redirected hosts.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class RedirectedHostsFragment extends AbstractListFragment {
@Override
protected boolean isTwoRowsItem() {
return true;
}
@Override
protected LiveData<PagingData<HostListItem>> getData() {
return this.mViewModel.getRedirectedListItems();
}
@Override
public void addItem() {
// Create dialog view
LayoutInflater factory = LayoutInflater.from(this.mActivity);
View view = factory.inflate(R.layout.lists_redirected_dialog, null);
EditText hostnameEditText = view.findViewById(R.id.list_dialog_hostname);
EditText ipEditText = view.findViewById(R.id.list_dialog_ip);
// Create dialog
AlertDialog alertDialog = new MaterialAlertDialogBuilder(this.mActivity)
.setCancelable(true)
.setTitle(R.string.list_add_dialog_redirect)
.setView(view)
// Setup buttons
.setPositiveButton(
R.string.button_add,
(dialog, which) -> {
// Close dialog
dialog.dismiss();
// Check if hostname and IP are valid
String hostname = hostnameEditText.getText().toString();
String ip = ipEditText.getText().toString();
if (RegexUtils.isValidHostname(hostname) && RegexUtils.isValidIP(ip)) {
// Insert host to redirection list
this.mViewModel.addListItem(ListType.REDIRECTED, hostname, ip);
}
}
)
.setNegativeButton(
R.string.button_cancel,
(dialog, which) -> dialog.dismiss()
)
.create();
// Show dialog
alertDialog.show();
// Set button validation behavior
AlertDialogValidator validator = new AlertDialogValidator(
alertDialog,
input -> {
String hostname = hostnameEditText.getText().toString();
String ip = ipEditText.getText().toString();
return RegexUtils.isValidHostname(hostname) && RegexUtils.isValidIP(ip);
},
false
);
hostnameEditText.addTextChangedListener(validator);
ipEditText.addTextChangedListener(validator);
}
@Override
protected void editItem(HostListItem item) {
// Create dialog view
LayoutInflater factory = LayoutInflater.from(this.mActivity);
View view = factory.inflate(R.layout.lists_redirected_dialog, null);
// Set hostname and IP
EditText hostnameEditText = view.findViewById(R.id.list_dialog_hostname);
EditText ipEditText = view.findViewById(R.id.list_dialog_ip);
hostnameEditText.setText(item.getHost());
ipEditText.setText(item.getRedirection());
// Move cursor to end of EditText
Editable hostnameEditContent = hostnameEditText.getText();
hostnameEditText.setSelection(hostnameEditContent.length());
// Create dialog
AlertDialog alertDialog = new MaterialAlertDialogBuilder(this.mActivity)
.setCancelable(true)
.setTitle(getString(R.string.list_edit_dialog_redirect))
.setView(view)
// Set buttons
.setPositiveButton(R.string.button_save,
(dialog, which) -> {
// Close dialog
dialog.dismiss();
// Check hostname and IP validity
String hostname = hostnameEditText.getText().toString();
String ip = ipEditText.getText().toString();
if (RegexUtils.isValidHostname(hostname) && RegexUtils.isValidIP(ip)) {
// Update list item
this.mViewModel.updateListItem(item, hostname, ip);
}
}
)
.setNegativeButton(
R.string.button_cancel,
(dialog, which) -> dialog.dismiss()
)
.create();
// Show dialog
alertDialog.show();
// Set button validation behavior
AlertDialogValidator validator = new AlertDialogValidator(
alertDialog,
input -> {
String hostname = hostnameEditText.getText().toString();
String ip = ipEditText.getText().toString();
return RegexUtils.isValidHostname(hostname) && RegexUtils.isValidIP(ip);
},
true
);
hostnameEditText.addTextChangedListener(validator);
ipEditText.addTextChangedListener(validator);
}
}

View file

@ -0,0 +1,235 @@
/*
* Copyright (C) 2011-2012 Dominik Schürmann <dominik@dominikschuermann.de>
*
* This file is part of AdAway.
*
* AdAway 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.
*
* AdAway 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 AdAway. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.adaway.ui.log;
import static org.adaway.ui.Animations.hideView;
import static org.adaway.ui.Animations.showView;
import static java.lang.Boolean.TRUE;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.adaway.R;
import org.adaway.databinding.LogActivityBinding;
import org.adaway.databinding.LogRedirectDialogBinding;
import org.adaway.db.entity.ListType;
import org.adaway.helper.ThemeHelper;
import org.adaway.ui.adblocking.ApplyConfigurationSnackbar;
import org.adaway.ui.dialog.AlertDialogValidator;
import org.adaway.util.Clipboard;
import org.adaway.util.RegexUtils;
/**
* This class is an {@link android.app.Activity} to show DNS request log entries.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
public class LogActivity extends AppCompatActivity implements LogViewCallback {
private LogActivityBinding binding;
/**
* The view model (<code>null</code> if activity is not created).
*/
private LogViewModel mViewModel;
/**
* The snackbar notification (<code>null</code> if activity is not created).
*/
private ApplyConfigurationSnackbar mApplySnackbar;
@Override
protected void onCreate(Bundle savedInstanceState) {
/*
* Create activity.
*/
super.onCreate(savedInstanceState);
ThemeHelper.applyTheme(this);
this.binding = LogActivityBinding.inflate(getLayoutInflater());
setContentView(this.binding.getRoot());
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayShowTitleEnabled(true);
actionBar.setDisplayHomeAsUpEnabled(true);
}
// Get view model
this.mViewModel = new ViewModelProvider(this).get(LogViewModel.class);
/*
* Configure swipe layout.
*/
this.binding.swipeRefresh.setOnRefreshListener(this.mViewModel::updateLogs);
/*
* Configure empty view.
*/
if (this.mViewModel.areBlockedRequestsIgnored()) {
this.binding.emptyTextView.append(getString(R.string.log_blocked_requests_ignored));
}
/*
* Configure recycler view.
*/
// Get recycler view
this.binding.logList.setHasFixedSize(true);
// Defile recycler layout
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
this.binding.logList.setLayoutManager(linearLayoutManager);
// Create recycler adapter
LogAdapter adapter = new LogAdapter(this);
this.binding.logList.setAdapter(adapter);
/*
* Configure fab.
*/
this.binding.toggleLogRecording.setOnClickListener(v -> this.mViewModel.toggleRecording());
this.mViewModel.isRecording().observe(this, recoding ->
this.binding.toggleLogRecording.setImageResource(TRUE.equals(recoding) ?
R.drawable.ic_pause_24dp :
R.drawable.ic_record_24dp
)
);
/*
* Configure snackbar.
*/
// Create apply snackbar
this.mApplySnackbar = new ApplyConfigurationSnackbar(this.binding.swipeRefresh, false, false);
/*
* Load data.
*/
// Bind view model to the list view
this.mViewModel.getLogs().observe(this, logEntries -> {
if (logEntries.isEmpty()) {
showView(this.binding.emptyTextView);
} else {
hideView(this.binding.emptyTextView);
}
adapter.submitList(logEntries);
this.binding.swipeRefresh.setRefreshing(false);
});
}
@Override
protected void onResume() {
super.onResume();
// Mark as loading data
this.binding.swipeRefresh.setRefreshing(true);
// Load initial data
this.mViewModel.updateLogs();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater menuInflater = getMenuInflater();
menuInflater.inflate(R.menu.log_menu, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.sort) {
this.mViewModel.toggleSort();
return true;
} else if (item.getItemId() == R.id.delete) {
this.mViewModel.clearLogs();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void addListItem(@NonNull String hostName, @NonNull ListType type) {
// Check view model and snackbar notification
if (this.mViewModel == null || this.mApplySnackbar == null) {
return;
}
// Check type other than redirection
if (type != ListType.REDIRECTED) {
// Insert list item
this.mViewModel.addListItem(hostName, type, null);
// Display snackbar notification
this.mApplySnackbar.notifyUpdateAvailable();
} else {
// Create dialog view
LayoutInflater inflater = LayoutInflater.from(this);
LogRedirectDialogBinding redirectBinding = LogRedirectDialogBinding.inflate(inflater);
// Create dialog
AlertDialog alertDialog = new MaterialAlertDialogBuilder(this)
.setCancelable(true)
.setTitle(R.string.log_redirect_dialog_title)
.setView(redirectBinding.getRoot())
// Setup buttons
.setPositiveButton(
R.string.button_add,
(dialog, which) -> {
// Close dialog
dialog.dismiss();
// Check IP is valid
String ip = redirectBinding.redirectIp.getText().toString();
if (RegexUtils.isValidIP(ip)) {
// Insert list item
this.mViewModel.addListItem(hostName, type, ip);
// Display snackbar notification
this.mApplySnackbar.notifyUpdateAvailable();
}
}
)
.setNegativeButton(
R.string.button_cancel,
(dialog, which) -> dialog.dismiss()
)
.create();
// Show dialog
alertDialog.show();
// Set button validation behavior
redirectBinding.redirectIp.addTextChangedListener(
new AlertDialogValidator(alertDialog, RegexUtils::isValidIP, false)
);
}
}
@Override
public void removeListItem(@NonNull String hostName) {
if (this.mViewModel != null && this.mApplySnackbar != null) {
this.mViewModel.removeListItem(hostName);
this.mApplySnackbar.notifyUpdateAvailable();
}
}
@Override
public void openHostInBrowser(@NonNull String hostName) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("http://" + hostName));
startActivity(intent);
}
@Override
public void copyHostToClipboard(@NonNull String hostName) {
Clipboard.copyHostToClipboard(this, hostName);
}
}

View file

@ -0,0 +1,107 @@
package org.adaway.ui.log;
import static android.graphics.PorterDuff.Mode.MULTIPLY;
import static org.adaway.db.entity.ListType.ALLOWED;
import static org.adaway.db.entity.ListType.BLOCKED;
import static org.adaway.db.entity.ListType.REDIRECTED;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.adaway.R;
import org.adaway.databinding.LogEntryBinding;
import org.adaway.db.entity.ListType;
/**
* This class is a the {@link RecyclerView.Adapter} for the DNS request log view.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
class LogAdapter extends ListAdapter<LogEntry, LogAdapter.ViewHolder> {
/**
* This callback is use to compare hosts sources.
*/
private static final DiffUtil.ItemCallback<LogEntry> DIFF_CALLBACK =
new DiffUtil.ItemCallback<LogEntry>() {
@Override
public boolean areItemsTheSame(@NonNull LogEntry oldEntry, @NonNull LogEntry newEntry) {
return oldEntry.getHost().equals(newEntry.getHost());
}
@Override
public boolean areContentsTheSame(@NonNull LogEntry oldEntry, @NonNull LogEntry newEntry) {
return oldEntry.equals(newEntry);
}
};
/**
* The activity view callback.
*/
private final LogViewCallback callback;
LogAdapter(LogViewCallback callback) {
super(DIFF_CALLBACK);
this.callback = callback;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
LogEntryBinding binding = LogEntryBinding.inflate(layoutInflater, parent, false);
return new ViewHolder(binding);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
// Get log entry
LogEntry entry = getItem(position);
// Set host name
holder.binding.hostnameTextView.setText(entry.getHost());
holder.binding.hostnameTextView.setOnClickListener(v -> this.callback.openHostInBrowser(entry.getHost()));
holder.binding.hostnameTextView.setOnLongClickListener(v -> {
this.callback.copyHostToClipboard(entry.getHost());
return true;
});
// Set type status
bindImageView(holder.binding.blockImageView, BLOCKED, entry);
bindImageView(holder.binding.allowImageView, ALLOWED, entry);
bindImageView(holder.binding.redirectionImageView, REDIRECTED, entry);
}
private void bindImageView(ImageView imageView, ListType type, LogEntry entry) {
if (type == entry.getType()) {
int primaryColor = this.callback.getColor(R.color.primary);
imageView.setColorFilter(primaryColor, MULTIPLY);
imageView.setOnClickListener(v -> this.callback.removeListItem(entry.getHost()));
} else {
imageView.clearColorFilter();
imageView.setOnClickListener(v -> this.callback.addListItem(entry.getHost(), type));
}
}
/**
* This class is a the {@link RecyclerView.ViewHolder} for the log entry view.
*
* @author Bruce BUJON (bruce.bujon(at)gmail(dot)com)
*/
static class ViewHolder extends RecyclerView.ViewHolder {
final org.adaway.databinding.LogEntryBinding binding;
/**
* Constructor.
*
* @param binding The log entry view binding.
*/
ViewHolder(LogEntryBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
}

Some files were not shown because too many files have changed in this diff Show more