Initial commit

This commit is contained in:
William Brawner 2022-10-29 21:46:27 -06:00
commit 549c7eceff
62 changed files with 1959 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name Normal file
View file

@ -0,0 +1 @@
Plausible Android

View file

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

20
.idea/gradle.xml Normal file
View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/plausible" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View file

@ -0,0 +1,37 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

10
.idea/misc.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# Plausible Android
This is an **unofficial** Android SDK to record events with a [Plausible] backend.
## Usage
### Configuration
For simple use cases, you can just declare the domain for which you'd like to send events in your `strings.xml` file:
```xml
<string name="plausible_domain">example.com</string>
```
If you're self-hosting Plausible, you'll need to provide the URL for your instance as well:
```xml
<string name="plausible_host">https://plausible.my-company.com</string>
```
By default, the SDK will be enabled at app startup, though you can prevent this to allow users to
opt-in or opt-out like so:
```xml
<string name="plausible_enable_startup">true</string>
```
You can then manually enable the sdk with the following:
```java
Plausible.enable(true)
```
If you'd like to set a static user agent, you can do that as well:
```xml
<string name="plausible_user_agent" />
```
Though it's probably best to use a unique user-agent for
### Sending Events
#### Page Views
```java
Plausible.pageView("/settings")
```
#### Custom Events
```java
Plausible.event("ctaClick")
```
## Download
```groovy
repositories {
mavenCentral()
}
dependencies {
implementation 'com.wbrawner.plausble:plausible-android:0.1.0-SNAPSHOT'
}
```
[Plausible]: https://plausible.io

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

72
app/build.gradle Normal file
View file

@ -0,0 +1,72 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.wbrawner.plausible.android.sample'
compileSdk 33
defaultConfig {
applicationId "com.wbrawner.plausible.android"
minSdk 21
targetSdk 33
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
signingConfigs {
release {
storeFile file('/Users/wbrawner/.android/debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation project(':plausible')
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation 'androidx.activity:activity-compose:1.6.0'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation 'androidx.compose.material:material:1.2.1'
implementation("androidx.navigation:navigation-compose:2.5.2")
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
}

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

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# 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

View file

@ -0,0 +1,24 @@
package com.wbrawner.plausible.android
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.wbrawner.plausible.android", appContext.packageName)
}
}

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PlausibleAndroid"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.PlausibleAndroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
</application>
</manifest>

View file

@ -0,0 +1,68 @@
package com.wbrawner.plausible.android.sample
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.wbrawner.plausible.android.Plausible
import com.wbrawner.plausible.android.sample.ui.CustomEventScreen
import com.wbrawner.plausible.android.sample.ui.MainScreen
import com.wbrawner.plausible.android.sample.ui.theme.PlausibleAndroidTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PlausibleAndroidTheme {
LaunchedEffect(key1 = Unit) {
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val navController = rememberNavController()
navController.addOnDestinationChangedListener { _, destination, _ ->
Plausible.pageView(destination.route?: "/")
}
NavHost(navController = navController, startDestination = "/") {
composable("/") {
MainScreen(navController)
}
composable("/page") {
CustomEventScreen(navController)
}
}
}
}
}
}
override fun onStart() {
super.onStart()
Plausible.pageView("app://localhost/")
Toast.makeText(this, "Sent pageview event", Toast.LENGTH_SHORT).show()
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
PlausibleAndroidTheme {
}
}

View file

@ -0,0 +1,48 @@
package com.wbrawner.plausible.android.sample.ui
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.wbrawner.plausible.android.Plausible
@Composable
fun CustomEventScreen(navController: NavController) {
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("Plausible Sample") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Go back")
}
}
)
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
Plausible.event("button-click", "/page")
Toast.makeText(context, "Sent button-click event", Toast.LENGTH_SHORT).show()
}
) {
Text("Send button click event")
}
}
}
}

View file

@ -0,0 +1,36 @@
package com.wbrawner.plausible.android.sample.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@Composable
fun MainScreen(navController: NavController) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Plausible Sample") }
)
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
navController.navigate("/page")
}
) {
Text("Go to next screen")
}
}
}
}

View file

@ -0,0 +1,8 @@
package com.wbrawner.plausible.android.sample.ui.theme
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)

View file

@ -0,0 +1,11 @@
package com.wbrawner.plausible.android.sample.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)

View file

@ -0,0 +1,47 @@
package com.wbrawner.plausible.android.sample.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
/* Other default colors to override
background = Color.White,
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.Black,
onSurface = Color.Black,
*/
)
@Composable
fun PlausibleAndroidTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}

View file

@ -0,0 +1,28 @@
package com.wbrawner.plausible.android.sample.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
)
/* Other default text styles to override
button = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.W500,
fontSize = 14.sp
),
caption = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp
)
*/
)

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,5 @@
<resources>
<string name="app_name">Plausible Android</string>
<string name="plausible_host">https://plausible.wbrawner.com</string>
<string name="plausible_domain">android.plausible.wbrawner.com</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PlausibleAndroid" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
</style>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View file

@ -0,0 +1,17 @@
package com.wbrawner.plausible.android
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

12
build.gradle Normal file
View file

@ -0,0 +1,12 @@
buildscript {
ext {
compose_ui_version = '1.2.1'
}
}
plugins {
id 'com.android.application' version '7.3.0' apply false
id 'com.android.library' version '7.3.0' apply false
id 'org.jetbrains.kotlin.android' version "1.7.20" apply false
id 'org.jetbrains.kotlin.plugin.serialization' version "1.7.20" apply false
}

23
gradle.properties Normal file
View file

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,6 @@
#Fri Sep 30 08:38:48 MDT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

185
gradlew vendored Executable file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
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.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java 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"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View file

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
plausible/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

75
plausible/build.gradle Normal file
View file

@ -0,0 +1,75 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'maven-publish'
id 'signing'
}
android {
namespace 'com.wbrawner.plausible.android'
compileSdk 33
defaultConfig {
minSdk 21
targetSdk 33
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
buildConfigField "String", "VERSION", "\"$versionName\""
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
publishing {
singleVariant("release") {
withSourcesJar()
withJavadocJar()
}
}
}
dependencies {
implementation "androidx.startup:startup-runtime:1.1.1"
implementation 'androidx.annotation:annotation:1.5.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
def okhttp_version = "4.10.0"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
testImplementation 'junit:junit:4.13.2'
}
afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
groupId 'com.wbrawner.plausible'
artifactId 'plausible-android'
version = android.defaultConfig.versionName
}
}
repositories {
// maven {
// name = 'local'
// url = "${project.buildDir}/repo"
// }
}
}
}

View file

@ -0,0 +1,26 @@
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault

21
plausible/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# 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

View file

@ -0,0 +1,24 @@
package com.wbrawner.plausible.android
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.wbrawner.plausible.android.test", appContext.packageName)
}
}

View file

@ -0,0 +1,17 @@
<?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-permission android:name="android.permission.INTERNET" />
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.wbrawner.plausible.android.PlausibleInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,46 @@
package com.wbrawner.plausible.android
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
/**
* @param domain Domain name of the site in Plausible.
* @param name Name of the event. Can specify `pageview` which is a special type of event in
* Plausible. All other names will be treated as custom events.
* @param url URL of the page where the event was triggered. If the URL contains UTM parameters,
* they will be extracted and stored.
* The URL parameter will feel strange in a mobile app but you can manufacture something that looks
* like a web URL. If you name your mobile app screens like page URLs, Plausible will know how to
* handle it. So for example, on your login screen you could send something like
* `app://localhost/login`. The pathname (/login) is what will be shown as the page value in the
* Plausible dashboard.
* @param referrer Referrer for this event.
* @param screenWidth Width of the screen in dp.
* @param props Custom properties for the event. See [https://plausible.io/docs/custom-event-goals#using-custom-props](https://plausible.io/docs/custom-event-goals#using-custom-props)
*/
@Serializable
internal data class Event(
val domain: String,
val name: String,
val url: String,
val referrer: String,
@SerialName("screen_width")
val screenWidth: Int,
// While this would be a lot more sensible as a map, Plausible < 1.5.0 expects the props to be
// double encoded for some reason. This will be fixed in 1.5.0 but not everyone who self-hosts
// will update immediately and it does appear that Plausible > 1.5.0 maintains backwards
// compatibility with the double encoded string, so it makes sense to continue doing things the
// old way for now. See the discussion linked below for more details:
// https://github.com/plausible/analytics/discussions/1570
val props: String?
) {
companion object {
fun fromJson(json: String): Event = Json.decodeFromString(json)
}
}
internal fun Event.toJson(): String = Json.encodeToString(this)

View file

@ -0,0 +1,119 @@
package com.wbrawner.plausible.android
import android.content.Context
/**
* Singleton for sending events to Plausible.
*
* If you disable the [PlausibleInitializer] then you
* must ensure that [init] is called prior to sending events.
*/
object Plausible {
internal lateinit var client: PlausibleClient
internal lateinit var config: PlausibleConfig
fun init(context: Context) {
val config = AndroidResourcePlausibleConfig(context)
val client = NetworkFirstPlausibleClient(config)
init(client, config)
}
internal fun init(client: PlausibleClient, config: PlausibleConfig) {
this.client = client
this.config = config
}
/**
* Enable or disable event sending
*/
@Suppress("unused")
fun enable(enable: Boolean) {
config.enable = enable
}
/**
* The raw value of User-Agent is used to calculate the user_id which identifies a unique
* visitor in Plausible.
* User-Agent is also used to populate the Devices report in your
* Plausible dashboard. The device data is derived from the open source database
* device-detector. If your User-Agent is not showing up in your dashboard, it's probably
* because it is not recognized as one in the device-detector database.
*/
@Suppress("unused")
fun setUserAgent(userAgent: String) {
config.userAgent = userAgent
}
/**
* Send a `pageview` event.
*
* @param domain Domain name of the site in Plausible.
* @param url URL of the page where the event was triggered. If the URL contains UTM parameters,
* they will be extracted and stored.
* The URL parameter will feel strange in a mobile app but you can manufacture something that looks
* like a web URL. If you name your mobile app screens like page URLs, Plausible will know how to
* handle it. So for example, on your login screen you could send something like
* `app://localhost/login`. The pathname (/login) is what will be shown as the page value in the
* Plausible dashboard.
* @param referrer Referrer for this event.
* Plausible uses the open source referer-parser database to parse referrers and assign these
* source categories.
* When no match has been found, the value of the referrer field will be parsed as an URL. The
* hostname will be used as the `visit:source` and the full URL as the `visit:referrer`. So if
* you send `https://some.domain.com/example-path`, it will be parsed as follows:
* `visit:source == some.domain.com` `visit:referrer == some.domain.com/example-path`
* @param screenWidth Width of the screen in dp.
* @param props Custom properties for the event. Values must be scalar. See [https://plausible.io/docs/custom-event-goals#using-custom-props](https://plausible.io/docs/custom-event-goals#using-custom-props)
* for more information.
*/
fun pageView(
url: String,
referrer: String = "",
domain: String = config.domain,
screenWidth: Int = config.screenWidth,
props: Map<String, Any?>? = null
) = event(
domain = domain,
name = "pageview",
url = url,
referrer = referrer,
screenWidth = screenWidth,
props = props
)
/**
* Send a custom event. To send a `pageview` event, consider using [pageView] instead.
*
* @param domain Domain name of the site in Plausible.
* @param name Name of the event. Can specify `pageview` which is a special type of event in
* Plausible. All other names will be treated as custom events.
* @param url URL of the page where the event was triggered. If the URL contains UTM parameters,
* they will be extracted and stored.
* The URL parameter will feel strange in a mobile app but you can manufacture something that looks
* like a web URL. If you name your mobile app screens like page URLs, Plausible will know how to
* handle it. So for example, on your login screen you could send something like
* `app://localhost/login`. The pathname (/login) is what will be shown as the page value in the
* Plausible dashboard.
* @param referrer Referrer for this event.
* Plausible uses the open source referer-parser database to parse referrers and assign these
* source categories.
* When no match has been found, the value of the referrer field will be parsed as an URL. The
* hostname will be used as the `visit:source` and the full URL as the `visit:referrer`. So if
* you send `https://some.domain.com/example-path`, it will be parsed as follows:
* `visit:source == some.domain.com` `visit:referrer == some.domain.com/example-path`
* @param screenWidth Width of the screen in dp.
* @param props Custom properties for the event. Values must be scalar. See [https://plausible.io/docs/custom-event-goals#using-custom-props](https://plausible.io/docs/custom-event-goals#using-custom-props)
* for more information.
*/
@Suppress("MemberVisibilityCanBePrivate")
fun event(
name: String,
url: String,
referrer: String = "",
domain: String = config.domain,
screenWidth: Int = config.screenWidth,
props: Map<String, Any?>? = null
) {
client.event(domain, name, url, referrer, screenWidth, props)
}
}

View file

@ -0,0 +1,177 @@
package com.wbrawner.plausible.android
import android.net.Uri
import android.util.Log
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException
import java.net.URL
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Implementation behind [Plausible] singleton. Using an instance under the singleton allows for
* easier testing.
*/
internal interface PlausibleClient {
/**
* See [Plausible.event] for details on parameters.
* @return true if the event was successfully processed and false if not
*/
fun event(
domain: String,
name: String,
url: String,
referrer: String,
screenWidth: Int,
props: Map<String, Any?>? = null
) {
var correctedUrl = Uri.parse(url)
if (correctedUrl.scheme.isNullOrBlank()) {
correctedUrl = correctedUrl.buildUpon().scheme("app").build()
}
if (correctedUrl.authority.isNullOrBlank()) {
correctedUrl = correctedUrl.buildUpon().authority("localhost").build()
}
return event(Event(
domain,
name,
correctedUrl.toString(),
referrer,
screenWidth,
props?.mapValues {
when (val value = it.value) {
is Number -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is String -> JsonPrimitive(value)
null -> JsonNull
else -> {
Log.w(
"PlausibleClient",
"Event props must be scalar. Value for prop \"${it.key}\" will be converted to String"
)
JsonPrimitive(value.toString())
}
}
}.run {
if (this?.isNotEmpty() == true) {
Json.encodeToString(this)
} else {
null
}
}
))
}
fun event(event: Event)
}
/**
* The primary client for sending events to Plausible. It will attempt to send events immediately,
* caching them to disk to send later upon failure.
*/
internal class NetworkFirstPlausibleClient(private val config: PlausibleConfig) : PlausibleClient {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
init {
coroutineScope.launch {
config.eventDir.mkdirs()
config.eventDir.listFiles()?.forEach {
val event = Event.fromJson(it.readText())
try {
postEvent(event)
} catch (e: IOException) {
return@forEach
}
it.delete()
}
}
}
override fun event(event: Event) {
coroutineScope.launch {
suspendEvent(event)
}
}
@VisibleForTesting
internal suspend fun suspendEvent(event: Event) {
try {
postEvent(event)
} catch (e: IOException) {
if (!config.retryOnFailure) return
val file = File(config.eventDir, "event_${System.currentTimeMillis()}.json")
file.writeText(event.toJson())
var retryAttempts = 0
var retryDelay = 1000L
while (retryAttempts < 5) {
delay(retryDelay)
retryDelay = when (retryDelay) {
1000L -> 60_000L
60_000L -> 360_000L
360_000L -> 600_000L
else -> break
}
try {
postEvent(event)
file.delete()
break
} catch(e: IOException) {
retryAttempts++
}
}
}
}
private suspend fun postEvent(event: Event) {
val body = event.toJson().toRequestBody("application/json".toMediaType())
val url = config.host
.toHttpUrl()
.newBuilder()
.addPathSegments("api/event")
.build()
val request = Request.Builder()
.url(url)
.addHeader("User-Agent", config.userAgent)
.post(body)
.build()
suspendCancellableCoroutine {
val call = okHttpClient.newCall(request)
it.invokeOnCancellation {
call.cancel()
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e("Plausible", "Failed to send event to backend", e)
it.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
if (response.isSuccessful) {
it.resume(Unit)
} else {
val e = IOException(
"Received unexpected response: ${response.code} ${response.body?.string()}"
)
onFailure(call, e)
}
}
})
}
}
companion object {
val okHttpClient: OkHttpClient = OkHttpClient()
}
}

View file

@ -0,0 +1,112 @@
package com.wbrawner.plausible.android
import android.content.Context
import android.content.res.Resources
import android.os.Build
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.roundToInt
private val DEFAULT_USER_AGENT =
"PlausibleAndroid ${BuildConfig.VERSION} Android ${Build.VERSION.RELEASE} ${Build.MANUFACTURER} ${Build.PRODUCT} ${Build.FINGERPRINT.hashCode()}"
private const val DEFAULT_PLAUSIBLE_HOST = "https://plausible.io"
/**
* Configuration options for the Plausible SDK. See the [Events API reference](https://plausible.io/docs/events-api)
* for more details on how these values are used by the backend.
*/
interface PlausibleConfig {
/**
* Domain name of the site in Plausible.
*/
var domain: String
/**
* Whether or not events should be sent. Use this to allow users to opt-in or opt-out for
* example.
*/
var enable: Boolean
/**
* Directory to persist events upon upload failure.
*/
val eventDir: File
/**
* The host for the Plausible backend server. Defaults to `https://plausible.io`.
*/
var host: String
/**
* Whether or not to attempt to resend events upon failure. If true, events will be serialized
* to disk in [eventDir] and the upload will be retried later.
*
* At the time of writing, the Plausible Events API doesn't support sending a timestamp for the
* event, so attempting to resend the event at a later time may skew analytics data.
*/
var retryOnFailure: Boolean
/**
* Width of the screen in dp.
*/
val screenWidth: Int
/**
* The raw value of User-Agent is used to calculate the user_id which identifies a unique
* visitor in Plausible.
* User-Agent is also used to populate the Devices report in your
* Plausible dashboard. The device data is derived from the open source database
* device-detector. If your User-Agent is not showing up in your dashboard, it's probably
* because it is not recognized as one in the device-detector database.
*/
var userAgent: String
}
open class ThreadSafePlausibleConfig(
override val eventDir: File,
override val screenWidth: Int
) : PlausibleConfig {
private val enableRef = AtomicBoolean()
override var enable: Boolean
get() = enableRef.get()
set(value) = enableRef.set(value)
private val domainRef = AtomicReference<String>()
override var domain: String
get() = domainRef.get()
set(value) = domainRef.set(value)
private val hostRef = AtomicReference(DEFAULT_PLAUSIBLE_HOST)
override var host: String
get() = hostRef.get()
set(value) = hostRef.set(value.ifBlank { DEFAULT_PLAUSIBLE_HOST })
private val retryRef = AtomicBoolean(true)
override var retryOnFailure: Boolean
get() = retryRef.get()
set(value) = retryRef.set(value)
private val userAgentRef = AtomicReference(DEFAULT_USER_AGENT)
override var userAgent: String
get() = userAgentRef.get()
set(value) = userAgentRef.set(value.ifBlank { DEFAULT_USER_AGENT })
}
class AndroidResourcePlausibleConfig(context: Context) : ThreadSafePlausibleConfig(
eventDir = File(context.applicationContext.filesDir, "events"),
screenWidth = with(Resources.getSystem().displayMetrics) {
widthPixels / density
}.roundToInt()
) {
init {
domain = context.resources.getString(R.string.plausible_domain)
host = context.resources.getString(R.string.plausible_host)
context.resources.getString(R.string.plausible_enable_startup).toBooleanStrictOrNull()
?.let {
enable = it
}
}
}

View file

@ -0,0 +1,18 @@
package com.wbrawner.plausible.android
import android.content.Context
import androidx.startup.Initializer
/**
* Automatically initializes the Plausible SDK for sending events.
*/
class PlausibleInitializer : Initializer<Plausible> {
override fun create(context: Context): Plausible {
Plausible.init(context.applicationContext)
return Plausible
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return emptyList()
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- `true` to automatically enable plausible on app startup or `false` to prevent sending events. -->
<string name="plausible_enable_startup">true</string>
<!-- Host for plausible backend. Defaults to plausible.io. -->
<string name="plausible_host">https://plausible.io</string>
<!-- Default domain to send events for. E.g. yourapp.com. -->
<string name="plausible_domain" />
</resources>

View file

@ -0,0 +1,17 @@
package com.wbrawner.plausible.android
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

17
settings.gradle Normal file
View file

@ -0,0 +1,17 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Plausible Android"
include ':app'
include ':plausible'