Migrate networking code to Kotlin Multiplatform compatible module

This commit is contained in:
William Brawner 2022-03-03 18:55:41 -07:00
parent 65838d6905
commit 09f261b034
31 changed files with 351 additions and 497 deletions

View file

@ -12,7 +12,7 @@
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/piholeclient" />
<option value="$PROJECT_DIR$/shared" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />

View file

@ -4,5 +4,17 @@
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

View file

@ -24,6 +24,10 @@
<entry key="../../../../layout/compose-model-1623358815178.xml" value="1.6830769230769231" />
<entry key="../../../../layout/compose-model-1623359670724.xml" value="0.11413043478260869" />
<entry key="../../../../layout/compose-model-1623360495184.xml" value="0.20880752102919348" />
<entry key="../../../../layout/compose-model-1646330539642.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1646331413004.xml" value="0.4506079027355623" />
<entry key="../../../../layout/compose-model-1646332602415.xml" value="0.13775083612040134" />
<entry key="../../../../layout/compose-model-1646358533232.xml" value="0.30153061224489797" />
<entry key="app/src/main/res/drawable-v26/ic_shortcut_enable.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable-v26/ic_shortcut_pause.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/background_splash.xml" value="0.409375" />

View file

@ -61,10 +61,13 @@ android {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.get()
}
}
dependencies {
implementation(project(":piholeclient"))
implementation(project(":shared"))
implementation(libs.bundles.coroutines)
implementation(libs.bundles.compose)
implementation(libs.hilt.android.core)

View file

@ -11,11 +11,13 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View file

@ -4,8 +4,8 @@ import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import com.wbrawner.piholeclient.PiHoleApiService
import com.wbrawner.piholeclient.VersionResponse
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.VersionResponse
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@ -22,7 +22,7 @@ const val IP_MAX = 255
@HiltViewModel
class AddPiHelperViewModel @Inject constructor(
private val sharedPreferences: SharedPreferences,
private val apiService: PiHoleApiService
private val apiService: PiholeAPIService
) : ViewModel() {
@Volatile

View file

@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.wbrawner.pihelper.shared.Status
import com.wbrawner.pihelper.ui.PihelperTheme
import com.wbrawner.piholeclient.Status
import java.util.*
import kotlin.math.pow
import kotlin.math.roundToLong
@ -168,7 +168,7 @@ fun CustomTimeDialog(
onTimeSelected: (Long) -> Unit
) {
if (!visible) return
val (time: Long, setTime: (Long) -> Unit) = remember { mutableStateOf(0L) }
val (time: String, setTime: (String) -> Unit) = remember { mutableStateOf("10") }
val (duration, selectDuration: (Duration) -> Unit) = remember {
mutableStateOf(Duration.SECONDS)
}

View file

@ -3,7 +3,8 @@ package com.wbrawner.pihelper
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import com.wbrawner.piholeclient.NAME_BASE_URL
import com.wbrawer.pihelper.shared.create
import com.wbrawner.pihelper.shared.PiholeAPIService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -13,6 +14,7 @@ import javax.inject.Named
import javax.inject.Singleton
const val ENCRYPTED_SHARED_PREFS_FILE_NAME = "pihelper.prefs"
const val NAME_BASE_URL = "baseUrl"
@Module
@InstallIn(SingletonComponent::class)
@ -33,4 +35,8 @@ object PiHelperModule {
@Named(NAME_BASE_URL)
fun providesBaseUrl(sharedPreferences: SharedPreferences) = sharedPreferences
.getString(KEY_BASE_URL, "")
@Provides
@Singleton
fun providesPiholeAPIService(): PiholeAPIService = PiholeAPIService.create()
}

View file

@ -1,9 +1,10 @@
package com.wbrawner.pihelper
import android.util.Log
import androidx.lifecycle.ViewModel
import com.wbrawner.piholeclient.PiHoleApiService
import com.wbrawner.piholeclient.Status
import com.wbrawner.piholeclient.StatusProvider
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.Status
import com.wbrawner.pihelper.shared.StatusProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -14,7 +15,7 @@ import kotlin.coroutines.coroutineContext
@HiltViewModel
class PiHelperViewModel @Inject constructor(
private val apiService: PiHoleApiService
private val apiService: PiholeAPIService
) : ViewModel() {
private val _status = MutableStateFlow(Status.LOADING)
val status = _status.asStateFlow()
@ -30,7 +31,7 @@ class PiHelperViewModel @Inject constructor(
_status.value = action!!.invoke().status
action = null
} catch (ignored: Exception) {
break
_status.value = Status.UNKNOWN
}
delay(1000)
}

View file

@ -55,10 +55,6 @@ fun ScanScreen(navController: NavController, addPiHelperViewModel: AddPiHelperVi
?.linkAddresses
?.filter { !it.address.isLoopbackAddress && it.address is Inet4Address }
?.forEach { address ->
Log.d(
"Pi-helper",
"Found link address: ${address.address.hostName}"
)
addPiHelperViewModel.beginScanning(
address.address.hostAddress,
onSuccess,

View file

@ -1,13 +1,10 @@
buildscript {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
val hiltVersion by extra("2.36")
val kotlinVersion by extra("1.4.32")
dependencies {
classpath("com.android.tools.build:gradle:7.0.0-beta03")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
classpath("com.google.dagger:hilt-android-gradle-plugin:$hiltVersion")
classpath(libs.bundles.plugins)
}
}

View file

@ -19,3 +19,6 @@ android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
kotlin.mpp.enableGranularSourceSetsMetadata=true
kotlin.native.enableDependencyPropagation=false

View file

@ -1,50 +1,66 @@
[versions]
androidx-core = "1.3.2"
androidx-appcompat = "1.2.0"
compose = "1.0.0-beta07"
compose = "1.1.1"
coroutines = "1.4.3"
espresso = "3.3.0"
hilt-android = "2.36"
kotlin = "1.4.32"
hilt-android = "2.38.1"
kotlin = "1.6.10"
kotlinx-serialization = "1.3.2"
kotlinx-coroutines = "1.6.0-native-mt"
ktor = "2.0.0-beta-1"
lifecycle = "2.2.0"
material = "1.3.0"
maxSdk = "30"
maxSdk = "31"
moshi = "1.9.2"
minSdk = "23"
navigation = "2.3.2"
navigation = "2.4.1"
okhttp = "4.2.2"
versionCode = "1"
versionName = "1.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
android-gradle = { module = "com.android.tools.build:gradle", version = "7.1.2"}
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.4.0" }
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.3.0-alpha07" }
compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0-alpha02" }
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "2.4.0-alpha01" }
material = { module = "com.google.android.material:material", version.ref = "material" }
moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
junit = { module = "junit:junit", version = "4.12" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
material = { module = "com.google.android.material:material", version.ref = "material" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
[bundles]
compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["coroutines-core", "coroutines-android"]
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt"]

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip

6
gradlew vendored
View file

@ -82,7 +82,7 @@ location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
which kotlin >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
@ -109,7 +109,7 @@ if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
# For Cygwin, switch paths to Windows format before running kotlin
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
@ -161,7 +161,7 @@ save () {
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
# Collect all arguments for the kotlin command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong

View file

@ -1 +0,0 @@
/build

View file

@ -1,45 +0,0 @@
plugins {
id("com.android.library")
id("kotlin-android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
compileSdk = libs.versions.maxSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
dependencies {
api("com.squareup.okhttp3:okhttp:${libs.versions.okhttp.get()}")
implementation("com.squareup.okhttp3:logging-interceptor:${libs.versions.okhttp.get()}")
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.bundles.coroutines)
implementation(libs.hilt.android.core)
kapt(libs.hilt.android.kapt)
implementation(libs.kotlin.reflect)
api(libs.moshi.core)
kapt(libs.moshi.codegen)
testImplementation(libs.junit)
}

View file

@ -1,67 +0,0 @@
# JSR 305 annotations are for embedding nullability information.
-dontwarn javax.annotation.**
-keepclasseswithmembers class * {
@com.squareup.moshi.* <methods>;
}
-keep @com.squareup.moshi.JsonQualifier interface *
# Enum field names are used by the integrated EnumJsonAdapter.
# values() is synthesized by the Kotlin compiler and is used by EnumJsonAdapter indirectly
# Annotate enums with @JsonClass(generateAdapter = false) to use them with Moshi.
-keepclassmembers @com.squareup.moshi.JsonClass class * extends java.lang.Enum {
<fields>;
**[] values();
}
# The name of @JsonClass types is used to look up the generated adapter.
-keepnames @com.squareup.moshi.JsonClass class *
# Retain generated target class's synthetic defaults constructor and keep DefaultConstructorMarker's
# name. We will look this up reflectively to invoke the type's constructor.
#
# We can't _just_ keep the defaults constructor because Proguard/R8's spec doesn't allow wildcard
# matching preceding parameters.
-keepnames class kotlin.jvm.internal.DefaultConstructorMarker
-keepclassmembers @com.squareup.moshi.JsonClass @kotlin.Metadata class * {
synthetic <init>(...);
}
# Retain generated JsonAdapters if annotated type is retained.
-if @com.squareup.moshi.JsonClass class *
-keep class <1>JsonAdapter {
<init>(...);
<fields>;
}
-if @com.squareup.moshi.JsonClass class **$*
-keep class <1>_<2>JsonAdapter {
<init>(...);
<fields>;
}
-if @com.squareup.moshi.JsonClass class **$*$*
-keep class <1>_<2>_<3>JsonAdapter {
<init>(...);
<fields>;
}
-if @com.squareup.moshi.JsonClass class **$*$*$*
-keep class <1>_<2>_<3>_<4>JsonAdapter {
<init>(...);
<fields>;
}
-if @com.squareup.moshi.JsonClass class **$*$*$*$*
-keep class <1>_<2>_<3>_<4>_<5>JsonAdapter {
<init>(...);
<fields>;
}
-if @com.squareup.moshi.JsonClass class **$*$*$*$*$*
-keep class <1>_<2>_<3>_<4>_<5>_<6>JsonAdapter {
<init>(...);
<fields>;
}
-keep class kotlin.reflect.jvm.internal.impl.builtins.BuiltInsLoaderImpl
-keepclassmembers class kotlin.Metadata {
public <methods>;
}

View file

@ -1,23 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keepclassmembers @com.squareup.moshi.JsonClass class *

View file

@ -1,146 +0,0 @@
package com.wbrawner.piholeclient
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.reflect.KClass
interface PiHoleApiService {
var baseUrl: String?
var apiKey: String?
suspend fun getSummary(
version: Boolean = false,
type: Boolean = false
): Summary
suspend fun getVersion(): VersionResponse
suspend fun getTopItems(): TopItemsResponse
suspend fun enable(): StatusResponse
suspend fun disable(duration: Long? = null): StatusResponse
/**
@Query("overTimeData10mins") overTimeData10mins: Boolean = true,
@Query("topItems") topItems: Int? = null,
@Query("topClients") topClients: Int? = null,
@Query("getForwardDestinations") getForwardDestinations: Boolean = true,
@Query("getQueryTypes") getQueryTypes: Boolean = true,
@Query("getAllQueries") getAllQueries: Boolean = true
*/
// suspend fun login(password: String): Response<String>
//
// @GET("/admin/scripts/pi-hole/php/api_token.php")
// suspend fun apiKey(phpSession: String): Response<String>
}
const val BASE_PATH = "/admin/api.php"
class OkHttpPiHoleApiService(
private val okHttpClient: OkHttpClient,
private val moshi: Moshi
) : PiHoleApiService {
override var baseUrl: String? = null
@Synchronized
get
@Synchronized
set
override var apiKey: String? = null
@Synchronized
get
@Synchronized
set
private val urlBuilder: HttpUrl.Builder
get() {
val host = baseUrl ?: throw IllegalStateException("No base URL defined")
return HttpUrl.Builder()
.scheme("http")
.host(host)
.addPathSegments(BASE_PATH)
}
override suspend fun getSummary(version: Boolean, type: Boolean): Summary {
val url = urlBuilder
.addQueryParameter("summary", "")
if (version) {
url.addQueryParameter("version", "")
}
if (type) {
url.addQueryParameter("type", "")
}
val request = Request.Builder()
.get()
.url(url.build())
return sendRequest(request.build(), Summary::class)!!
}
override suspend fun getVersion(): VersionResponse {
val url = urlBuilder
.addQueryParameter("version", "")
val request = Request.Builder()
.get()
.url(url.build())
return sendRequest(request.build(), VersionResponse::class)!!
}
override suspend fun getTopItems(): TopItemsResponse {
val apiToken = this.apiKey ?: throw java.lang.IllegalStateException("No API Token provided")
val url = urlBuilder
.addQueryParameter("topItems", "25")
.addQueryParameter("auth", apiToken)
val request = Request.Builder()
.get()
.url(url.build())
return sendRequest(request.build(), TopItemsResponse::class)!!
}
override suspend fun enable(): StatusResponse {
val apiToken = this.apiKey ?: throw java.lang.IllegalStateException("No API Token provided")
val url = urlBuilder
.addQueryParameter("enable", "")
.addQueryParameter("auth", apiToken)
val request = Request.Builder()
.get()
.url(url.build())
return sendRequest(request.build(), StatusResponse::class)!!
}
override suspend fun disable(duration: Long?): StatusResponse {
val apiToken = this.apiKey ?: throw java.lang.IllegalStateException("No API Token provided")
val url = urlBuilder
.addQueryParameter("disable", duration?.toString()?: "")
.addQueryParameter("auth", apiToken)
val request = Request.Builder()
.get()
.url(url.build())
return sendRequest(request.build(), StatusResponse::class)!!
}
private suspend fun <T : Any> sendRequest(request: Request, responseType: KClass<T>?): T? {
return withContext(Dispatchers.IO) {
val response = okHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
null
} else {
@Suppress("UNCHECKED_CAST")
when (responseType) {
null -> null
String::class -> response.body?.string() as T
else -> response.body?.let {
moshi
.adapter(responseType.javaObjectType)
.fromJson(it.source())
}
}
}
}
}
}

View file

@ -1,71 +0,0 @@
package com.wbrawner.piholeclient
import com.squareup.moshi.JsonReader
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import okio.Buffer
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
const val NAME_BASE_URL = "baseUrl"
@Module
@InstallIn(SingletonComponent::class)
object PiHoleClientModule {
@Provides
@Singleton
fun providesMoshi(): Moshi = Moshi.Builder().build()
@Provides
@Singleton
fun providesOkHttpClient(): OkHttpClient {
val client = OkHttpClient.Builder()
.connectTimeout(500, TimeUnit.MILLISECONDS)
.cookieJar(object : CookieJar {
val cookies = mutableMapOf<String, List<Cookie>>()
override fun loadForRequest(url: HttpUrl): List<Cookie> = cookies[url.host]
?: emptyList()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
this.cookies[url.host] = cookies
}
})
if (BuildConfig.DEBUG) {
client.addInterceptor(HttpLoggingInterceptor(
object : HttpLoggingInterceptor.Logger {
val moshi = Moshi.Builder()
.build()
.adapter(Any::class.java)
.indent(" ")
override fun log(message: String) {
val prettyMessage = try {
val json = JsonReader.of(Buffer().writeUtf8(message))
moshi.toJson(json.readJsonValue())
} catch (ignored: Exception) {
message
}
HttpLoggingInterceptor.Logger.DEFAULT.log(prettyMessage)
}
})
.apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
}
return client.build()
}
@Provides
@Singleton
fun providesPiHoleApiService(okHttpClient: OkHttpClient, moshi: Moshi): PiHoleApiService =
OkHttpPiHoleApiService(okHttpClient, moshi)
}

View file

@ -1,89 +0,0 @@
package com.wbrawner.piholeclient
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Summary(
@Json(name = "domains_being_blocked")
val domainsBeingBlocked: String,
@Json(name = "dns_queries_today")
val dnsQueriesToday: String,
@Json(name = "ads_blocked_today")
val adsBlockedToday: String,
@Json(name = "ads_percentage_today")
val adsPercentageToday: String,
@Json(name = "unique_domains")
val uniqueDomains: String,
@Json(name = "queries_forwarded")
val queriesForwarded: String,
@Json(name = "clients_ever_seen")
val clientsEverSeen: String,
@Json(name = "unique_clients")
val uniqueClients: String,
@Json(name = "dns_queries_all_types")
val dnsQueriesAllTypes: String,
@Json(name = "queries_cached")
val queriesCached: String,
@Json(name = "no_data_replies")
val noDataReplies: String?,
@Json(name = "nx_domain_replies")
val nxDomainReplies: String?,
@Json(name = "cname_replies")
val cnameReplies: String?,
@Json(name = "in_replies")
val ipReplies: String?,
@Json(name = "privacy_level")
val privacyLevel: String,
override val status: Status,
@Json(name = "gravity_last_updated")
val gravity: Gravity?,
val type: String?,
val version: Int?
) : StatusProvider
@Keep
enum class Status {
@Json(name = "enabled")
ENABLED,
@Json(name = "disabled")
DISABLED,
@Transient
LOADING,
@Transient
UNKNOWN
}
@JsonClass(generateAdapter = true)
data class Gravity(
@Json(name = "file_exists")
val fileExists: Boolean,
val absolute: Int,
val relative: Relative
)
@JsonClass(generateAdapter = true)
data class Relative(
val days: String,
val hours: String,
val minutes: String
)
@JsonClass(generateAdapter = true)
data class VersionResponse(val version: Int)
@JsonClass(generateAdapter = true)
data class TopItemsResponse(
@Json(name = "top_queries") val topQueries: Map<String, String>,
@Json(name = "top_ads") val topAds: Map<String, Double>
)
@JsonClass(generateAdapter = true)
data class StatusResponse(
override val status: Status
) : StatusProvider
interface StatusProvider {
val status: Status
}

View file

@ -1,3 +0,0 @@
<resources>
<string name="app_name">PiHoleClient</string>
</resources>

View file

@ -8,4 +8,4 @@ dependencyResolutionManagement {
}
rootProject.name = "Pi-helper"
include(":app")
include(":piholeclient")
include(":shared")

1
shared/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

63
shared/build.gradle.kts Normal file
View file

@ -0,0 +1,63 @@
plugins {
kotlin("multiplatform")
id("com.android.library")
kotlin("plugin.serialization") version "1.6.10"
}
kotlin {
android()
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "Pihelper"
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
}
val androidMain by getting {
dependencies {
implementation(libs.ktor.client.android)
}
}
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
dependencies {
implementation(libs.ktor.client.ios)
}
}
}
}
android {
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
compileSdk = libs.versions.maxSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.wbrawner.piholeclient">
package="com.wbrawner.pihelper">
<uses-permission android:name="android.permission.INTERNET" />
<application android:usesCleartextTraffic="true" />
</manifest>
</manifest>

View file

@ -0,0 +1,11 @@
package com.wbrawer.pihelper.shared
import com.wbrawner.pihelper.shared.KtorPiholeAPIService
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.commonConfig
import io.ktor.client.*
import io.ktor.client.engine.android.*
fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Android) {
commonConfig()
})

View file

@ -0,0 +1,82 @@
package com.wbrawner.pihelper.shared
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
const val BASE_PATH = "/admin/api.php"
abstract class PiholeAPIService {
abstract var baseUrl: String?
abstract var apiKey: String?
abstract suspend fun getSummary(): Summary
abstract suspend fun getVersion(): VersionResponse
abstract suspend fun getTopItems(): TopItemsResponse
abstract suspend fun enable(): StatusResponse
abstract suspend fun disable(duration: Long? = null): StatusResponse
companion object
}
fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override var baseUrl: String? = null
override var apiKey: String? = null
override suspend fun getSummary(): Summary = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
encodedPath = BASE_PATH
}
}.body()
override suspend fun getVersion(): VersionResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
encodedPath = BASE_PATH
parameter("version", "")
}
}.body()
override suspend fun getTopItems(): TopItemsResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
encodedPath = BASE_PATH
parameter("topItems", "25")
parameter("auth", apiKey)
}
}.body()
override suspend fun enable(): StatusResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
encodedPath = BASE_PATH
parameter("enable", "")
parameter("auth", apiKey)
}
}.body()
override suspend fun disable(duration: Long?): StatusResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
encodedPath = BASE_PATH
parameter("disable", duration?.toString()?: "")
parameter("auth", apiKey)
}
}.body()
}

View file

@ -0,0 +1,90 @@
package com.wbrawner.pihelper.shared
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable()
data class Summary(
@SerialName("domains_being_blocked")
val domainsBeingBlocked: String? = null,
@SerialName("dns_queries_today")
val dnsQueriesToday: String? = null,
@SerialName("ads_blocked_today")
val adsBlockedToday: String? = null,
@SerialName("ads_percentage_today")
val adsPercentageToday: String? = null,
@SerialName("unique_domains")
val uniqueDomains: String? = null,
@SerialName("queries_forwarded")
val queriesForwarded: String? = null,
@SerialName("clients_ever_seen")
val clientsEverSeen: String? = null,
@SerialName("unique_clients")
val uniqueClients: String? = null,
@SerialName("dns_queries_all_types")
val dnsQueriesAllTypes: String? = null,
@SerialName("queries_cached")
val queriesCached: String? = null,
@SerialName("no_data_replies")
val noDataReplies: String? = null,
@SerialName("nx_domain_replies")
val nxDomainReplies: String? = null,
@SerialName("cname_replies")
val cnameReplies: String? = null,
@SerialName("in_replies")
val ipReplies: String? = null,
@SerialName("privacy_level")
val privacyLevel: String,
override val status: Status,
@SerialName("gravity_last_updated")
val gravity: Gravity? = null,
val type: String? = null,
val version: Int? = null
) : StatusProvider
@Serializable
enum class Status {
@SerialName("enabled")
ENABLED,
@SerialName("disabled")
DISABLED,
@kotlinx.serialization.Transient
LOADING,
@kotlinx.serialization.Transient
UNKNOWN,
@kotlinx.serialization.Transient
ERROR
}
@Serializable()
data class Gravity(
@SerialName("file_exists")
val fileExists: Boolean,
val absolute: Int,
val relative: Relative
)
@Serializable()
data class Relative(
val days: String,
val hours: String,
val minutes: String
)
@Serializable()
data class VersionResponse(val version: Int)
@Serializable()
data class TopItemsResponse(
@SerialName("top_queries") val topQueries: Map<String, String>,
@SerialName("top_ads") val topAds: Map<String, Double>
)
@Serializable()
data class StatusResponse(
override val status: Status
) : StatusProvider
interface StatusProvider {
val status: Status
}

View file

@ -0,0 +1,11 @@
package com.wbrawer.pihelper.shared
import com.wbrawner.pihelper.shared.KtorPiholeAPIService
import com.wbrawner.pihelper.shared.PiholeAPIService
import io.ktor.client.*
import com.wbrawner.pihelper.shared.commonConfig
import io.ktor.client.engine.darwin.*
fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Darwin) {
commonConfig()
})