Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion aw-server-rust
Submodule aw-server-rust updated 57 files
+11 −11 .github/workflows/build.yml
+2 −5 .github/workflows/lint.yml
+25 −0 .pre-commit-config.yaml
+1,367 −811 Cargo.lock
+4 −1 Makefile
+37 −0 README.md
+9 −2 aw-client-rust/Cargo.toml
+0 −2 aw-client-rust/README.md
+22 −2 aw-client-rust/src/blocking.rs
+144 −0 aw-client-rust/src/classes.rs
+135 −36 aw-client-rust/src/lib.rs
+484 −0 aw-client-rust/src/queries.rs
+84 −0 aw-client-rust/src/single_instance.rs
+139 −0 aw-client-rust/tests/status_errors.rs
+179 −5 aw-client-rust/tests/test.rs
+3 −2 aw-datastore/Cargo.toml
+66 −32 aw-datastore/src/datastore.rs
+5 −5 aw-datastore/src/legacy_import.rs
+1 −0 aw-datastore/src/lib.rs
+420 −0 aw-datastore/src/privacy_filter.rs
+110 −7 aw-datastore/src/worker.rs
+41 −4 aw-datastore/tests/datastore.rs
+5 −0 aw-models/src/lib.rs
+76 −0 aw-models/src/settings.rs
+1 −1 aw-query/Cargo.toml
+22 −2 aw-query/src/datatype.rs
+0 −3 aw-query/src/functions.rs
+55 −0 aw-query/tests/query.rs
+2 −2 aw-server.service
+10 −6 aw-server/Cargo.toml
+6 −0 aw-server/build.rs
+145 −5 aw-server/src/android/mod.rs
+96 −3 aw-server/src/config.rs
+1 −1 aw-server/src/device_id.rs
+79 −13 aw-server/src/dirs.rs
+279 −0 aw-server/src/endpoints/apikey.rs
+21 −21 aw-server/src/endpoints/bucket.rs
+1 −0 aw-server/src/endpoints/cors.rs
+98 −3 aw-server/src/endpoints/import.rs
+22 −0 aw-server/src/endpoints/mod.rs
+0 −3 aw-server/src/lib.rs
+12 −3 aw-server/src/main.rs
+89 −7 aw-server/tests/api.rs
+18 −4 aw-sync/Cargo.toml
+70 −13 aw-sync/README.md
+241 −0 aw-sync/src/android.rs
+11 −5 aw-sync/src/dirs.rs
+4 −1 aw-sync/src/lib.rs
+176 −69 aw-sync/src/main.rs
+12 −3 aw-sync/src/sync.rs
+8 −27 aw-sync/src/sync_wrapper.rs
+1 −1 aw-sync/src/util.rs
+2 −2 aw-transform/Cargo.toml
+70 −9 aw-transform/src/classify.rs
+35 −5 aw-transform/src/split_url.rs
+1 −1 aw-webui
+9 −0 compile-android.sh
140 changes: 140 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/DashboardAuth.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package net.activitywatch.android

import android.content.Context
import java.io.File
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID

private const val CONFIG_FILE_NAME = "config.toml"
private const val AUTH_SECTION = "auth"

private val sectionHeaderPattern = Regex("""^\s*\[([^\]]+)]\s*(?:#.*)?$""")
private val apiKeyPattern = Regex("""^\s*api_key\s*=\s*(['"])(.*?)\1\s*(?:#.*)?$""")

fun ensureDashboardApiKey(context: Context): String {
val configFile = File(context.filesDir, CONFIG_FILE_NAME)
val currentConfig = if (configFile.isFile) configFile.readText() else ""
val existingApiKey = extractApiKey(currentConfig)
if (existingApiKey != null) {
return existingApiKey
}

val generatedApiKey = UUID.randomUUID().toString()
configFile.writeText(upsertApiKey(currentConfig, generatedApiKey))
return generatedApiKey
}

internal fun buildDashboardUrl(baseUrl: String, apiKey: String?): String {
val normalizedApiKey = apiKey?.trim().orEmpty()
if (normalizedApiKey.isEmpty()) {
return baseUrl
}
val parsedUrl = URI(baseUrl)
val queryParts = mutableListOf<String>()

if (!parsedUrl.rawQuery.isNullOrBlank()) {
queryParts.add(parsedUrl.rawQuery)
}
queryParts.add(
"token=${URLEncoder.encode(normalizedApiKey, StandardCharsets.UTF_8.name())}"
)

val normalizedPath = parsedUrl.path?.takeIf { it.isNotEmpty() } ?: "/"
return URI(
parsedUrl.scheme,
parsedUrl.authority,
normalizedPath,
queryParts.joinToString("&"),
parsedUrl.fragment,
).toASCIIString()
}

internal fun extractApiKey(config: String): String? {
var inAuthSection = false

for (line in config.lineSequence()) {
val trimmed = line.trim()
if (trimmed.startsWith("#")) {
continue
}

val sectionName = parseSectionName(trimmed)
if (sectionName != null) {
inAuthSection = sectionName == AUTH_SECTION
continue
}

if (!inAuthSection) {
continue
}

val apiKeyMatch = apiKeyPattern.matchEntire(trimmed) ?: continue
val apiKey = apiKeyMatch.groupValues[2].trim()
if (apiKey.isNotEmpty()) {
return apiKey
}
}

return null
}

internal fun upsertApiKey(config: String, apiKey: String): String {
val escapedApiKey = escapeTomlString(apiKey)
val renderedApiKey = """api_key = "$escapedApiKey""""
val result = mutableListOf<String>()
var inAuthSection = false
var insertedApiKey = false

for (line in config.lineSequence()) {
val trimmed = line.trim()
val sectionName = parseSectionName(trimmed)

if (sectionName != null) {
if (inAuthSection && !insertedApiKey) {
result.add(renderedApiKey)
insertedApiKey = true
}
inAuthSection = sectionName == AUTH_SECTION
result.add(line)
continue
}

if (inAuthSection && apiKeyPattern.matchEntire(trimmed) != null) {
if (!insertedApiKey) {
result.add(renderedApiKey)
insertedApiKey = true
}
continue
}

result.add(line)
}

if (inAuthSection && !insertedApiKey) {
result.add(renderedApiKey)
insertedApiKey = true
}

if (!insertedApiKey) {
if (result.isNotEmpty() && result.last().isNotBlank()) {
result.add("")
}
result.add("[auth]")
result.add(renderedApiKey)
}

return result.joinToString("\n").let { rendered ->
if (rendered.endsWith("\n")) rendered else "$rendered\n"
}
}

private fun escapeTomlString(value: String): String {
return value.replace("\\", "\\\\").replace("\"", "\\\"")
}

private fun parseSectionName(line: String): String? {
val match = sectionHeaderPattern.matchEntire(line) ?: return null
return match.groupValues[1].trim().lowercase()
}
21 changes: 15 additions & 6 deletions mobile/src/main/java/net/activitywatch/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@ const val baseURL = "http://127.0.0.1:5600"
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, WebUIFragment.OnFragmentInteractionListener {

private lateinit var binding: ActivityMainBinding
private lateinit var dashboardApiKey: String

val version: String
get() {
return packageManager.getPackageInfo(packageName, 0).versionName
}

private fun authenticatedUrl(url: String = baseURL): String {
return buildDashboardUrl(url, dashboardApiKey)
}

private fun openDashboardInBrowser(url: String = baseURL) {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(authenticatedUrl(url))))
}

override fun onFragmentInteraction(item: Uri) {
Log.w(TAG, "URI onInteraction listener not implemented")
}
Expand Down Expand Up @@ -57,12 +66,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
binding.navView.setNavigationItemSelectedListener(this)

val ri = RustInterface(this)
dashboardApiKey = ensureDashboardApiKey(this)
ri.startServerTask(this)

if (savedInstanceState != null) {
return
}
val firstFragment = WebUIFragment.newInstance(baseURL)
val firstFragment = WebUIFragment.newInstance(authenticatedUrl())
supportFragmentManager.beginTransaction()
.add(R.id.fragment_container, firstFragment).commit()
}
Expand Down Expand Up @@ -115,19 +125,18 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}
R.id.nav_activity -> {
fragmentClass = WebUIFragment::class.java
url = "$baseURL/#/activity/unknown/"
url = authenticatedUrl("$baseURL/#/activity/unknown/")
}
R.id.nav_buckets -> {
fragmentClass = WebUIFragment::class.java
url = "$baseURL/#/buckets/"
url = authenticatedUrl("$baseURL/#/buckets/")
}
R.id.nav_settings -> {
fragmentClass = WebUIFragment::class.java
url = "$baseURL/#/settings/"
url = authenticatedUrl("$baseURL/#/settings/")
}
R.id.nav_share -> {
Snackbar.make(binding.coordinatorLayout, "The share button was clicked, but it's not yet implemented!", Snackbar.LENGTH_LONG)
.setAction("Action", null).show()
openDashboardInBrowser()
}
R.id.nav_send -> {
Snackbar.make(binding.coordinatorLayout, "The send button was clicked, but it's not yet implemented!", Snackbar.LENGTH_LONG)
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/main/res/menu/activity_main_drawer.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<item
android:id="@+id/nav_share"
android:icon="@drawable/ic_menu_share"
android:title="Share"/>
android:title="@string/open_in_browser"/>
<item
android:id="@+id/nav_send"
android:icon="@drawable/ic_menu_send"
Expand Down
1 change: 1 addition & 0 deletions mobile/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<string name="nav_header_subtitle">v0.1.0</string>
<string name="nav_header_desc">Navigation header</string>
<string name="action_settings">Settings</string>
<string name="open_in_browser">Open in browser</string>
<string name="title_activity_buckets">BucketsActivity</string>
<string name="welcome_title_text">Welcome, early user!</string>
<string name="welcome_text">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package net.activitywatch.android

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class DashboardAuthTest {
@Test
fun buildDashboardUrl_skipsTokenWhenAuthDisabled() {
assertEquals("http://127.0.0.1:5600", buildDashboardUrl(baseURL, null))
}

@Test
fun buildDashboardUrl_appendsEncodedTokenBeforeHashRoute() {
assertEquals(
"http://127.0.0.1:5600/?token=secret%2B+%2F%3F%3D%26#/activity/unknown/",
buildDashboardUrl(
"$baseURL/#/activity/unknown/",
"secret+ /?=&",
),
)
}

@Test
fun extractApiKey_readsExistingAuthSection() {
val config = """
address = "127.0.0.1"

[auth]
api_key = "existing-token"
""".trimIndent()

assertEquals("existing-token", extractApiKey(config))
}

@Test
fun extractApiKey_ignoresCommentedDefaults() {
val config = """
### DEFAULT SETTINGS ###
#[auth]
#api_key = "commented"
""".trimIndent()

assertNull(extractApiKey(config))
}

@Test
fun upsertApiKey_appendsAuthSectionWhenMissing() {
val updated = upsertApiKey("address = \"127.0.0.1\"\n", "generated-token")

assertEquals(
"address = \"127.0.0.1\"\n\n[auth]\napi_key = \"generated-token\"\n",
updated,
)
}

@Test
fun upsertApiKey_replacesExistingValueWithoutDroppingOtherSections() {
val config = """
[auth]
api_key = "old-token"

[custom_static]
test = "/tmp/static"
""".trimIndent()

assertEquals(
"[auth]\napi_key = \"new-token\"\n\n[custom_static]\ntest = \"/tmp/static\"\n",
upsertApiKey(config, "new-token"),
)
}
}
Loading