Compare commits

..

4 commits

17 changed files with 69 additions and 334 deletions

5
.gitignore vendored
View file

@ -9,3 +9,8 @@
.externalNativeBuild
.cxx
/receiver/receiver
# Never commit signing secrets
/keystore.properties
*.jks
*.keystore

12
LICENSE Normal file
View file

@ -0,0 +1,12 @@
Copyright (c) 2026 Lily Goscha, All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3- Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -1,3 +1,5 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@ -5,17 +7,36 @@ plugins {
}
android {
namespace = "com.example.filedrop"
namespace = "com.bllry.filedrop"
compileSdk = 36
defaultConfig {
applicationId = "com.example.filedrop"
applicationId = "com.bllry.filedrop"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
// Release signing is enabled only when keystore.properties is present (local
// release builds). F-Droid's build server has no such file, so it builds
// unsigned; F-Droid then verifies the developer-published APK reproduces.
// Signing via the Gradle build (not external apksigner) keeps the APK's zip
// layout identical to the unsigned build, which is required for apksigcopier.
val keystorePropsFile = rootProject.file("keystore.properties")
val releaseSigning = if (keystorePropsFile.exists()) {
val props = Properties()
keystorePropsFile.inputStream().use { props.load(it) }
signingConfigs.create("release") {
storeFile = rootProject.file(props.getProperty("storeFile"))
storePassword = props.getProperty("storePassword")
keyAlias = props.getProperty("keyAlias")
keyPassword = props.getProperty("keyPassword")
}
} else {
null
}
buildTypes {
release {
isMinifyEnabled = false
@ -23,6 +44,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = releaseSigning
}
}

View file

@ -1,4 +1,4 @@
package com.example.filedrop
package com.bllry.filedrop
import android.os.Bundle
import androidx.activity.ComponentActivity

View file

@ -1,4 +1,4 @@
package com.example.filedrop
package com.bllry.filedrop
import android.app.Application
import android.net.Uri

View file

@ -1,4 +1,4 @@
package com.example.filedrop
package com.bllry.filedrop
import android.content.Context
import androidx.datastore.core.DataStore

View file

@ -1,4 +1,4 @@
package com.example.filedrop
package com.bllry.filedrop
import android.content.ContentResolver
import android.net.Uri

View file

@ -0,0 +1,21 @@
File Drop is a minimal tool for sending files, photos, and videos from your
Android phone to a PC on the same local network — no cloud, no accounts, no
sign-in.
Enter your PC's LAN address once (for example 192.168.1.42:8787) and an
optional shared token, then tap "Pick files" and choose what to send. Each file
streams straight to a small receiver program running on your PC, with a progress
bar per file. Large videos stream without loading into memory.
Everything stays on your own network. The app only needs the INTERNET
permission to reach the PC over Wi-Fi; it uses no Google services and no
tracking.
Features:
* Send any file type using the system file picker (no storage permission needed)
* Per-file upload progress and retry on failure
* Optional shared-secret token so only you can drop files onto your PC
* Settings saved locally on the device
You must run the companion receiver on your PC for the app to have somewhere to
send files.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -0,0 +1 @@
Send files and photos from your phone to a PC on the same Wi-Fi.

View file

@ -0,0 +1 @@
File Drop

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

99
plan.md
View file

@ -1,99 +0,0 @@
# Android → PC File Drop
Minimal Kotlin Android app that picks files/images and uploads them to a folder on a PC over the local network.
## Architecture
Two pieces:
1. **PC receiver** — tiny HTTP server that accepts multipart uploads and writes files into a chosen folder.
2. **Android app** — one screen, one button: pick files → POST them to the PC.
No cloud, no accounts, no discovery protocol. You type (or QR-scan) the PC's LAN IP once and save it.
## PC Receiver
**Language:** Go (single binary). Go for "double-click and forget."
**Behavior:**
- Listen on `0.0.0.0:8787`.
- `POST /upload` accepts `multipart/form-data`, saves each part to `DROP_DIR` with its original filename.
- Collision policy: append ` (1)`, ` (2)`, etc.
- Optional shared-secret header (`X-Auth: <token>`) so randos on the same Wi-Fi can't dump files on you.
- `GET /` returns "ok" for a health check.
- Log each received file: name, size, source IP.
it is seen on the local panel so that user locally can download
**Config:**
- `DROP_DIR` — where files land (default `~/Drops`).
- `AUTH_TOKEN` — shared secret (generated on first run, printed + saved to config).
- `PORT` — default 8787.
**Firewall:** open the port on the PC's LAN profile only.
## Android App
**Stack:** Kotlin, Jetpack Compose, single Activity, minSdk 26.
**Screens:** one.
**UI:**
- Text field: PC address (e.g. `192.168.1.42:8787`), persisted in `DataStore`.
- Text field: auth token, persisted.
- Big button: "Pick files".
- Below: list of currently-selected files with progress bars during upload.
- Snackbar for success/failure per file.
**Flow:**
1. Tap button → `ActivityResultContracts.OpenMultipleDocuments()` (handles images, video, any file; no storage permission needed on modern Android).
2. For each returned `Uri`: stream it as a multipart part to `http://<address>/upload` with the auth header.
3. Show per-file progress; on 200, mark done; on error, show retry.
**Networking:** OkHttp with a `MultipartBody`, streaming from `contentResolver.openInputStream(uri)` so big videos don't OOM.
**Manifest:**
- `INTERNET` permission.
- `usesCleartextTraffic="true"` scoped to local IP ranges via `network_security_config.xml` (HTTP is fine on LAN; adding TLS is a stretch goal).
**Share target (stretch):** register as a share target so you can hit "Share → Drop to PC" from the Photos app.
## Protocol
```
POST /upload HTTP/1.1
Host: 192.168.1.42:8787
X-Auth: <token>
Content-Type: multipart/form-data; boundary=...
--boundary
Content-Disposition: form-data; name="file"; filename="IMG_1234.jpg"
Content-Type: image/jpeg
<bytes>
--boundary--
```
Response: `200 OK` with JSON `{"saved": "IMG_1234.jpg"}` or `4xx/5xx` with error text.
## Milestones
1. PC receiver in ~60 lines; test with `curl -F file=@foo.jpg http://localhost:8787/upload -H "X-Auth: ..."`.
2. Android app scaffold: Compose UI, DataStore for settings, file picker returning URIs.
3. Wire up OkHttp multipart upload from a URI stream.
4. Progress + error UI.
5. Package: PC receiver as a systemd user service (Linux) or Task Scheduler entry (Windows) so it autostarts. Android side sideload the APK.
## Stretch
- QR code on PC receiver's `GET /` page encoding `address + token` so first-time setup is scan-once.
- mDNS advertisement (`_filedrop._tcp.local`) so the app can discover the PC without typing IPs.
- TLS with a self-signed cert pinned in the app.
- Share-target intent filter.
- Resume interrupted uploads (`Content-Range`).
## Non-goals
- PC → Android direction (use MTP or a second app instance later).
- Internet / NAT traversal.
- Multiple PCs at once (one address at a time is fine).

View file

@ -1,3 +0,0 @@
module file-share/receiver
go 1.22

View file

@ -1,225 +0,0 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"html"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)
func main() {
home, _ := os.UserHomeDir()
cfgDir := filepath.Join(home, ".file-share")
_ = os.MkdirAll(cfgDir, 0o755)
cfgPath := filepath.Join(cfgDir, "receiver.env")
dropDir := env("DROP_DIR", filepath.Join(home, "Drops"))
port := env("PORT", "8787")
host := env("HOST", lanAddress())
authToken := env("AUTH_TOKEN", "")
if authToken == "" {
authToken = loadOrGenerateToken(cfgPath)
}
_ = os.MkdirAll(dropDir, 0o755)
log.Printf("drop dir: %s", dropDir)
log.Printf("auth token: %s", authToken)
log.Printf("listening on http://%s:%s", host, port)
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
mux.HandleFunc("POST /upload", handleUpload(dropDir, authToken))
mux.HandleFunc("GET /files", handleList(dropDir))
mux.HandleFunc("GET /download", handleDownload(dropDir))
log.Fatal(http.ListenAndServe(host+":"+port, mux))
}
func handleUpload(dropDir, authToken string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if authToken != "" && r.Header.Get("X-Auth") != authToken {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files := r.MultipartForm.File["file"]
if len(files) == 0 {
http.Error(w, "no file", http.StatusBadRequest)
return
}
type savedFile struct {
Saved string `json:"saved"`
}
saved := make([]savedFile, 0, len(files))
for _, fh := range files {
src, err := fh.Open()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dest := uniquePath(dropDir, filepath.Base(fh.Filename))
dst, err := os.Create(dest)
if err != nil {
_ = src.Close()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
n, err := io.Copy(dst, src)
_ = src.Close()
_ = dst.Close()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
name := filepath.Base(dest)
log.Printf("saved %s (%d bytes) from %s", name, n, r.RemoteAddr)
saved = append(saved, savedFile{Saved: name})
}
w.Header().Set("Content-Type", "application/json")
if len(saved) == 1 {
fmt.Fprintf(w, `{"saved":%q}`, saved[0].Saved)
return
}
fmt.Fprint(w, "[")
for i, s := range saved {
if i > 0 {
fmt.Fprint(w, ",")
}
fmt.Fprintf(w, `{"saved":%q}`, s.Saved)
}
fmt.Fprint(w, "]")
}
}
func handleList(dropDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(dropDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>Drops</title></head><body><h1>Drops</h1><ul>`)
for _, e := range entries {
if e.IsDir() {
continue
}
info, _ := e.Info()
name := e.Name()
fmt.Fprintf(w, `<li><a href="/download?name=%s">%s</a> (%s)</li>`,
url.QueryEscape(name), html.EscapeString(name), formatBytes(info.Size()))
}
fmt.Fprint(w, `</ul></body></html>`)
}
}
func handleDownload(dropDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "missing name", http.StatusBadRequest)
return
}
path := filepath.Join(dropDir, filepath.Base(name))
http.ServeFile(w, r, path)
}
}
func uniquePath(dir, filename string) string {
if filename == "" {
filename = "unnamed"
}
ext := filepath.Ext(filename)
base := strings.TrimSuffix(filename, ext)
dest := filepath.Join(dir, filename)
if _, err := os.Stat(dest); os.IsNotExist(err) {
return dest
}
for i := 1; ; i++ {
candidate := filepath.Join(dir, base+" ("+strconv.Itoa(i)+")"+ext)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
}
func loadOrGenerateToken(path string) string {
if data, err := os.ReadFile(path); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "AUTH_TOKEN=") {
return strings.TrimPrefix(line, "AUTH_TOKEN=")
}
}
}
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
log.Fatal(err)
}
token := hex.EncodeToString(b)
_ = os.WriteFile(path, []byte("AUTH_TOKEN="+token+"\n"), 0o600)
log.Printf("generated token, saved to %s", path)
return token
}
func lanAddress() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "127.0.0.1"
}
var fallback string
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil {
continue
}
ip := ipnet.IP.To4()
if ip[0] == 192 && ip[1] == 168 {
return ip.String()
}
if fallback == "" && (ip[0] == 10 || (ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31)) {
fallback = ip.String()
}
}
if fallback != "" {
return fallback
}
return "127.0.0.1"
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func formatBytes(n int64) string {
if n < 1024 {
return strconv.FormatInt(n, 10) + " B"
}
units := []string{"KB", "MB", "GB", "TB"}
f := float64(n) / 1024
for _, u := range units {
if f < 1024 || u == units[len(units)-1] {
return fmt.Sprintf("%.1f %s", f, u)
}
f /= 1024
}
return strconv.FormatInt(n, 10) + " B"
}

Binary file not shown.