Add Plausible analytics

This commit is contained in:
William Brawner 2022-12-23 21:28:14 -07:00
parent f2fb3f6c2e
commit 6c18dc6d1d
12 changed files with 123 additions and 25 deletions

View file

@ -26,5 +26,10 @@
<option name="name" value="MavenRepo" /> <option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" /> <option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository> </remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://s01.oss.sonatype.org/content/repositories/snapshots/" />
</remote-repository>
</component> </component>
</project> </project>

View file

@ -26,10 +26,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Action import com.wbrawner.pihelper.shared.*
import com.wbrawner.pihelper.shared.AuthenticationString
import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Store
const val AUTH_SCREEN_TAG = "authScreen" const val AUTH_SCREEN_TAG = "authScreen"
const val SUCCESS_TEXT_TAG = "successText" const val SUCCESS_TEXT_TAG = "successText"

View file

@ -22,8 +22,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Action import com.wbrawner.pihelper.shared.*
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.ui.PihelperTheme
@Composable @Composable
@ -107,6 +106,11 @@ fun InfoScreen(onBackClicked: () -> Unit, onForgetPiholeClicked: () -> Unit) {
) { ) {
message.getStringAnnotations(it, it).firstOrNull()?.let { annotation -> message.getStringAnnotations(it, it).firstOrNull()?.let { annotation ->
uriHandler.openUri(annotation.item) uriHandler.openUri(annotation.item)
// TODO: Move this to the store?
PlausibleAnalyticsHelper.event(
AnalyticsEvent.LinkClicked(annotation.item),
Route.ABOUT
)
} }
} }
TextButton(onClick = onForgetPiholeClicked) { TextButton(onClick = onForgetPiholeClicked) {

View file

@ -65,7 +65,6 @@ class MainActivity : AppCompatActivity() {
val state by store.state.collectAsState() val state by store.state.collectAsState()
val navController = rememberNavController() val navController = rememberNavController()
LaunchedEffect(state.route) { LaunchedEffect(state.route) {
println("navigating to ${state.route.name}")
navController.navigate(state.route.name) navController.navigate(state.route.name)
} }
val effect by store.effects.collectAsState(initial = Effect.Empty) val effect by store.effects.collectAsState(initial = Effect.Empty)

View file

@ -1,8 +1,6 @@
package com.wbrawner.pihelper package com.wbrawner.pihelper
import com.wbrawner.pihelper.shared.PiholeAPIService import com.wbrawner.pihelper.shared.*
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.shared.create
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -16,9 +14,14 @@ object PiHelperModule {
@Singleton @Singleton
fun providesPiholeAPIService(): PiholeAPIService = PiholeAPIService.create() fun providesPiholeAPIService(): PiholeAPIService = PiholeAPIService.create()
@Provides
@Singleton
fun providesAnalyticsHelper(): AnalyticsHelper = PlausibleAnalyticsHelper
@Provides @Provides
@Singleton @Singleton
fun providesStore( fun providesStore(
apiService: PiholeAPIService, apiService: PiholeAPIService,
): Store = Store(apiService) analyticsHelper: AnalyticsHelper
): Store = Store(apiService, analyticsHelper)
} }

View file

@ -7,4 +7,6 @@
<string name="action_disable_30_seconds_short">Disable for 30 seconds</string> <string name="action_disable_30_seconds_short">Disable for 30 seconds</string>
<string name="action_disable_5_minutes">Disable for 5 minutes</string> <string name="action_disable_5_minutes">Disable for 5 minutes</string>
<string name="action_disable_5_minutes_short">Disable for 5 minutes</string> <string name="action_disable_5_minutes_short">Disable for 5 minutes</string>
<string name="plausible_domain">pihelper.android.wbrawner.com</string>
<string name="plausible_host">https://plausible.wbrawner.com</string>
</resources> </resources>

View file

@ -1,3 +1,5 @@
import java.net.URI
buildscript { buildscript {
repositories { repositories {
gradlePluginPortal() gradlePluginPortal()
@ -13,5 +15,8 @@ allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven {
url = URI("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}
} }
} }

View file

@ -19,6 +19,7 @@ maxSdk = "33"
minSdk = "23" minSdk = "23"
navigation = "2.4.1" navigation = "2.4.1"
okhttp = "4.10.0" okhttp = "4.10.0"
plausible = "0.1.0-SNAPSHOT"
settings = "0.8.1" settings = "0.8.1"
versionCode = "1" versionCode = "1"
versionName = "1.0" versionName = "1.0"
@ -66,6 +67,7 @@ multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" } navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
plausible = { module = "com.wbrawner.plausible:plausible-android", version.ref = "plausible" }
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" } preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
test-ext = { module = "androidx.test.ext:junit", version = "1.1.4" } test-ext = { module = "androidx.test.ext:junit", version = "1.1.4" }

View file

@ -29,6 +29,7 @@ kotlin {
val androidMain by getting { val androidMain by getting {
dependencies { dependencies {
implementation(libs.ktor.client.android) implementation(libs.ktor.client.android)
implementation(libs.plausible)
} }
} }

View file

@ -0,0 +1,18 @@
package com.wbrawner.pihelper.shared
import com.wbrawner.plausible.android.Plausible
object PlausibleAnalyticsHelper : AnalyticsHelper {
override fun pageView(route: Route) {
Plausible.pageView(route.path)
}
override fun event(event: AnalyticsEvent, route: Route) {
val props = when (event) {
is AnalyticsEvent.DisableButtonClicked -> mapOf("duration" to event.duration)
is AnalyticsEvent.LinkClicked -> mapOf("link" to event.link)
else -> null
}
Plausible.event(event.name, route.path, props = props)
}
}

View file

@ -0,0 +1,23 @@
package com.wbrawner.pihelper.shared
interface AnalyticsHelper {
fun pageView(route: Route)
fun event(event: AnalyticsEvent, route: Route)
companion object
}
sealed class AnalyticsEvent(val name: String) {
object ScanButtonClick : AnalyticsEvent("Scan button clicked")
object ConnectButtonClick : AnalyticsEvent("Connect button clicked")
object AuthenticateWithPasswordButtonClicked
: AnalyticsEvent("Authenticate with password button clicked")
object AuthenticateWithApiKeyButtonClicked
: AnalyticsEvent("Authenticate with API key button clicked")
object EnableButtonClicked : AnalyticsEvent("Enable button clicked")
data class DisableButtonClicked(val duration: Long?) : AnalyticsEvent("Disable button clicked")
object ForgetButtonClicked : AnalyticsEvent("Forget button clicked")
data class LinkClicked(val link: String) : AnalyticsEvent("Link clicked")
}

View file

@ -63,6 +63,7 @@ private const val ONE_SECOND = 1_000
class Store( class Store(
private val apiService: PiholeAPIService, private val apiService: PiholeAPIService,
private val analyticsHelper: AnalyticsHelper? = null,
private val settings: Settings = Settings(), private val settings: Settings = Settings(),
initialState: State = State() initialState: State = State()
) : CoroutineScope by CoroutineScope(Dispatchers.Main) { ) : CoroutineScope by CoroutineScope(Dispatchers.Main) {
@ -74,14 +75,12 @@ class Store(
private var scanJob: Job? = null private var scanJob: Job? = null
init { init {
launch { var previousRoute: Route? = null
_state.collect {
println(it)
}
}
val host: String? = initialState.host ?: settings[KEY_HOST] val host: String? = initialState.host ?: settings[KEY_HOST]
val apiKey: String? = initialState.apiKey ?: settings[KEY_API_KEY] val apiKey: String? = initialState.apiKey ?: settings[KEY_API_KEY]
if (!host.isNullOrBlank() && !apiKey.isNullOrBlank()) { if (!host.isNullOrBlank() && !apiKey.isNullOrBlank()) {
// This avoids reporting a page view for the connect page when it isn't actually viewed
previousRoute = Route.CONNECT
apiService.baseUrl = host apiService.baseUrl = host
apiService.apiKey = apiKey apiService.apiKey = apiKey
_state.value = initialState.copy( _state.value = initialState.copy(
@ -96,6 +95,16 @@ class Store(
connect("pi.hole", false) connect("pi.hole", false)
} }
} }
launch {
delay(1000)
_state.collect {
println(it)
if (it.route != previousRoute) {
previousRoute = it.route
analyticsHelper?.pageView(it.route)
}
}
}
} }
fun dispatch(action: Action) { fun dispatch(action: Action) {
@ -104,17 +113,47 @@ class Store(
is Action.Authenticate -> { is Action.Authenticate -> {
when (action.authString) { when (action.authString) {
// The Pi-hole API key is just the web password hashed twice with SHA-256 // The Pi-hole API key is just the web password hashed twice with SHA-256
is AuthenticationString.Password -> authenticate( is AuthenticationString.Password -> {
authenticate(
action.authString.value.hash().hash() action.authString.value.hash().hash()
) )
is AuthenticationString.Token -> authenticate(action.authString.value) analyticsHelper?.event(
AnalyticsEvent.AuthenticateWithPasswordButtonClicked,
_state.value.route
)
}
is AuthenticationString.Token -> {
authenticate(action.authString.value)
analyticsHelper?.event(
AnalyticsEvent.AuthenticateWithApiKeyButtonClicked,
_state.value.route
)
} }
} }
is Action.Connect -> connect(action.host) }
is Action.Disable -> disable(action.duration) is Action.Connect -> {
Action.Enable -> enable() connect(action.host)
Action.Forget -> forget() analyticsHelper?.event(AnalyticsEvent.ConnectButtonClick, _state.value.route)
is Action.Scan -> scan(action.deviceIp) }
is Action.Disable -> {
disable(action.duration)
analyticsHelper?.event(
AnalyticsEvent.DisableButtonClicked(action.duration),
_state.value.route
)
}
Action.Enable -> {
enable()
analyticsHelper?.event(AnalyticsEvent.EnableButtonClicked, _state.value.route)
}
Action.Forget -> {
forget()
analyticsHelper?.event(AnalyticsEvent.ForgetButtonClicked, _state.value.route)
}
is Action.Scan -> {
scan(action.deviceIp)
analyticsHelper?.event(AnalyticsEvent.ScanButtonClick, _state.value.route)
}
Action.About -> _state.value = _state.value.copy(route = Route.ABOUT) Action.About -> _state.value = _state.value.copy(route = Route.ABOUT)
Action.Back -> back() Action.Back -> back()
} }