Flatten repo: move client/ contents to repo root
CI / Type-Check (push) Successful in 16s
CI / Build Android APK (push) Successful in 5m20s

The project is no longer a monorepo/workspace - there is only a single
app package. Remove the redundant client/ nesting: all source, the
Capacitor Android project, config files and package.json/tsconfig now
live directly at the repo root.

- Merge client/package.json into root package.json (drop the
  workspaces field and the now-unnecessary bun --cwd client wrappers).
- Merge client/bun.lock (the actual dependency lockfile) into the
  root, replacing the stale workspace-only root lockfile.
- Move src/, public/, android/, index.html, vite.config.ts,
  capacitor.config.ts, tsconfig.{app,node}.json to the repo root.
- Update Dockerfile.android to build from the repo root instead of
  cd'ing into client/.
- Update .github/workflows/ci.yml type-check step to run bun run build
  directly instead of bun --cwd client run build.
- Update README.md and .github/copilot-instructions.md to drop
  references to the client/ subdirectory.
- Drop the stale server/downloads/ entry from .gitignore (leftover
  from an old server-based architecture that no longer exists).
This commit is contained in:
2026-08-13 21:52:32 +02:00
parent e3e725cf7b
commit 8ef7b9ce8c
88 changed files with 80 additions and 1039 deletions
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+69
View File
@@ -0,0 +1,69 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
namespace "com.tiptoisync.app"
compileSdk rootProject.ext.compileSdkVersion
signingConfigs {
release {
storeFile file('tiptoi-debug.keystore')
storePassword 'tiptoisync'
keyAlias 'tiptoisync'
keyPassword 'tiptoisync'
}
}
defaultConfig {
applicationId "com.tiptoisync.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
debug {
signingConfig signingConfigs.release
}
release {
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+19
View File
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
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
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
</manifest>
@@ -0,0 +1,31 @@
package com.tiptoisync.app
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "Dev")
class DevPlugin : Plugin() {
private val prefs get() = context.getSharedPreferences("CapacitorStorage", 0)
/** Speichert die Dev-Server-URL und startet die Activity neu,
* damit die Bridge mit der neuen URL initialisiert wird. */
@PluginMethod
fun activate(call: PluginCall) {
val url = call.getString("url") ?: return call.reject("url required")
prefs.edit().putString("devServerUrl", url).apply()
activity.runOnUiThread { activity.recreate() }
call.resolve()
}
/** Entfernt die Dev-Server-URL und startet die Activity neu,
* damit die Bridge wieder die gebundelte App lädt. */
@PluginMethod
fun deactivate(call: PluginCall) {
prefs.edit().remove("devServerUrl").apply()
activity.runOnUiThread { activity.recreate() }
call.resolve()
}
}
@@ -0,0 +1,29 @@
package com.tiptoisync.app
import android.os.Bundle
import androidx.core.view.WindowCompat
import com.getcapacitor.BridgeActivity
import com.getcapacitor.CapConfig
class MainActivity : BridgeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Dev-URL aus SharedPreferences lesen, bevor die Bridge initialisiert wird.
// "CapacitorStorage" ist der von @capacitor/preferences genutzte SharedPreferences-Name.
val devUrl = getSharedPreferences("CapacitorStorage", MODE_PRIVATE)
.getString("devServerUrl", null)
if (devUrl != null) {
// CapConfig mit der Dev-Server-URL überschreiben die Bridge lädt dann
// direkt vom Vite Dev-Server und injiziert die Bridge trotzdem korrekt.
config = CapConfig.Builder(this)
.setServerUrl(devUrl)
.setAllowMixedContent(true)
.create()
}
registerPlugin(TiptoiPlugin::class.java)
registerPlugin(DevPlugin::class.java)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
}
}
@@ -0,0 +1,264 @@
package com.tiptoisync.app
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import androidx.activity.result.ActivityResult
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.ActivityCallback
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
@CapacitorPlugin(name = "Tiptoi")
class TiptoiPlugin : Plugin() {
private val downloadsDir get() = File(context.filesDir, "downloads").also { it.mkdirs() }
private val prefs get() = context.getSharedPreferences("tiptoi", 0)
// ── Stift (SAF) ──────────────────────────────────────────────────────────
@PluginMethod
fun requestPenAccess(call: PluginCall) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
startActivityForResult(call, intent, "onPenDirectoryPicked")
}
@ActivityCallback
private fun onPenDirectoryPicked(call: PluginCall?, result: ActivityResult) {
val uri = result.data?.data ?: run { call?.reject("Abgebrochen"); return }
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
prefs.edit().putString("pen_uri", uri.toString()).apply()
call?.resolve(JSObject().put("uri", uri.toString()))
}
@PluginMethod
fun getPenUri(call: PluginCall) {
val uri = prefs.getString("pen_uri", null)
val result = JSObject()
if (uri != null) result.put("uri", uri)
call.resolve(result)
}
@PluginMethod
fun listPenFiles(call: PluginCall) {
val uriStr = call.getString("uri") ?: return call.reject("uri fehlt")
val treeUri = Uri.parse(uriStr)
val childUri = DocumentsContract.buildChildDocumentsUriUsingTree(
treeUri, DocumentsContract.getTreeDocumentId(treeUri)
)
val cursor = context.contentResolver.query(
childUri,
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null, null, null
) ?: return call.reject("Verzeichnis nicht lesbar")
val files = JSArray()
cursor.use {
while (it.moveToNext()) {
val name = it.getString(0)
if (name.endsWith(".gme", ignoreCase = true)) files.put(name)
}
}
call.resolve(JSObject().put("files", files))
}
@PluginMethod
fun copyToPen(call: PluginCall) {
val uriStr = call.getString("uri") ?: return call.reject("uri fehlt")
val filename = call.getString("filename") ?: return call.reject("filename fehlt")
val src = File(downloadsDir, filename)
if (!src.exists()) return call.reject("Datei nicht im Cache: $filename")
Thread {
try {
val treeUri = Uri.parse(uriStr)
val treeDocUri = DocumentsContract.buildDocumentUriUsingTree(
treeUri, DocumentsContract.getTreeDocumentId(treeUri)
)
// Existierende Datei löschen (z. B. 0-Byte-Rest eines Fehlversuchs)
val existingUri = findDocInTree(treeUri, filename)
if (existingUri != null) {
DocumentsContract.deleteDocument(context.contentResolver, existingUri)
}
// Neue, leere Datei anlegen
val targetUri = DocumentsContract.createDocument(
context.contentResolver,
treeDocUri,
"application/octet-stream",
filename
) ?: throw IOException("Konnte Datei nicht anlegen: $filename")
val total = src.length()
var bytesCopied = 0L
val buffer = ByteArray(256 * 1024)
// openFileDescriptor statt openOutputStream: gibt echten fd zurück,
// damit wir sync() = fsync() aufrufen können
val pfd = context.contentResolver.openFileDescriptor(targetUri, "wt")
?: throw IOException("Konnte Datei nicht öffnen: $filename")
pfd.use { descriptor ->
FileOutputStream(descriptor.fileDescriptor).use { out ->
FileInputStream(src).use { input ->
var read: Int
while (input.read(buffer).also { read = it } != -1) {
out.write(buffer, 0, read)
bytesCopied += read
notifyListeners("copyProgress", JSObject()
.put("filename", filename)
.put("bytes", bytesCopied)
.put("total", total))
}
out.flush()
}
}
// fsync: Kernel-Pagecache → physische SD-Karte flushen.
// Für MTP-Geräte wirft dies (kein echtes fd) ignorieren,
// MTP committed bereits beim close() des Streams.
try { descriptor.fileDescriptor.sync() } catch (_: Exception) {}
}
// Verifikation: tatsächlich geschriebene Bytes prüfen
val written = context.contentResolver.query(
targetUri,
arrayOf(DocumentsContract.Document.COLUMN_SIZE),
null, null, null
)?.use { c -> if (c.moveToFirst()) c.getLong(0) else -1L } ?: -1L
if (written != total) {
throw IOException("Schreibfehler: $written von $total Bytes angekommen")
}
call.resolve()
} catch (e: Exception) {
call.reject(e.message ?: "Fehler beim Kopieren")
}
}.start()
}
private fun findDocInTree(treeUri: Uri, filename: String): Uri? {
val childUri = DocumentsContract.buildChildDocumentsUriUsingTree(
treeUri, DocumentsContract.getTreeDocumentId(treeUri)
)
val cursor = context.contentResolver.query(
childUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
),
null, null, null
) ?: return null
cursor.use {
while (it.moveToNext()) {
if (it.getString(1) == filename) {
return DocumentsContract.buildDocumentUriUsingTree(treeUri, it.getString(0))
}
}
}
return null
}
// ── Downloads (App-Cache) ────────────────────────────────────────────────
@PluginMethod
fun listDownloads(call: PluginCall) {
val gmeFiles = downloadsDir
.listFiles { f -> f.extension.equals("gme", ignoreCase = true) }
?: emptyArray()
val books = JSArray()
for (gme in gmeFiles) {
val base = gme.nameWithoutExtension
val cover = downloadsDir.listFiles { f ->
f.nameWithoutExtension == base && !f.extension.equals("gme", ignoreCase = true)
}?.firstOrNull()
books.put(JSObject().put("filename", gme.name).put("coverPath", cover?.absolutePath).put("size", gme.length()))
}
call.resolve(JSObject().put("books", books))
}
@PluginMethod
fun downloadGme(call: PluginCall) {
val url = call.getString("url") ?: return call.reject("url fehlt")
val filename = call.getString("filename") ?: return call.reject("filename fehlt")
Thread {
try {
downloadToFile(url, File(downloadsDir, filename))
call.resolve(JSObject().put("filename", filename))
} catch (e: Exception) {
call.reject(e.message ?: "Download fehlgeschlagen")
}
}.start()
}
@PluginMethod
fun downloadCover(call: PluginCall) {
val url = call.getString("url") ?: return call.reject("url fehlt")
val filename = call.getString("filename") ?: return call.reject("filename fehlt")
Thread {
try { downloadToFile(url, File(downloadsDir, filename)) }
catch (_: Exception) { /* Cover ist optional */ }
call.resolve()
}.start()
}
// ── Löschen ────────────────────────────────────────────────────────────
@PluginMethod
fun deleteDownload(call: PluginCall) {
val filename = call.getString("filename") ?: return call.reject("filename fehlt")
val gme = File(downloadsDir, File(filename).name)
if (!gme.exists()) return call.reject("Datei nicht gefunden: $filename")
val base = gme.nameWithoutExtension
gme.delete()
downloadsDir.listFiles { f ->
f.nameWithoutExtension == base && !f.extension.equals("gme", ignoreCase = true)
}?.forEach { it.delete() }
call.resolve()
}
@PluginMethod
fun deletePenFile(call: PluginCall) {
val uriStr = call.getString("uri") ?: return call.reject("uri fehlt")
val filename = call.getString("filename") ?: return call.reject("filename fehlt")
val treeUri = Uri.parse(uriStr)
val docUri = findDocInTree(treeUri, filename)
?: return call.reject("Datei nicht auf Stift gefunden: $filename")
val deleted = DocumentsContract.deleteDocument(context.contentResolver, docUri)
if (deleted) call.resolve() else call.reject("Löschen fehlgeschlagen")
}
// ── Hilfsfunktionen ──────────────────────────────────────────────────────
private fun downloadToFile(url: String, dest: File) {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 15_000
conn.readTimeout = 60_000
conn.connect()
if (conn.responseCode != 200) throw IOException("HTTP ${conn.responseCode}")
conn.inputStream.use { input -> FileOutputStream(dest).use { input.copyTo(it) } }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -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="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -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="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#12181f</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">tiptoi Sync</string>
<string name="title_activity_main">tiptoi Sync</string>
<string name="package_name">com.tiptoisync.app</string>
<string name="custom_url_scheme">com.tiptoisync.app</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
Binary file not shown.