Skip to content

Commit d9d9e6b

Browse files
committed
feat: media viewer added, actions workflow added
1 parent 5a36e2b commit d9d9e6b

9 files changed

Lines changed: 843 additions & 65 deletions

File tree

.github/workflows/release.yml

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
name: Build & Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
permissions:
8+
contents: write # needed to create tags and GitHub releases
9+
10+
jobs:
11+
release:
12+
name: Build and publish release
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
# ── 1. Checkout with full tag history ───────────────────────────
17+
- name: Checkout
18+
uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0 # required to see all existing tags
21+
22+
# ── 2. Compute the next minor version ───────────────────────────
23+
- name: Compute next version
24+
id: version
25+
run: |
26+
git fetch --tags --force
27+
28+
# Find the latest semver tag (vMAJOR.MINOR.PATCH)
29+
LATEST=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
30+
31+
if [ -z "$LATEST" ]; then
32+
# No tags yet — start at v0.1.0
33+
NEW_TAG="v0.1.0"
34+
else
35+
MAJOR=$(echo "$LATEST" | cut -d. -f1 | tr -d 'v')
36+
MINOR=$(echo "$LATEST" | cut -d. -f2)
37+
NEW_MINOR=$((MINOR + 1))
38+
NEW_TAG="v${MAJOR}.${NEW_MINOR}.0"
39+
fi
40+
41+
echo "previous=${LATEST:-none}"
42+
echo "tag=$NEW_TAG"
43+
echo "tag=$NEW_TAG" >> "$GITHUB_OUTPUT"
44+
45+
# ── 3. Set up Go ────────────────────────────────────────────────
46+
- name: Set up Go
47+
uses: actions/setup-go@v5
48+
with:
49+
go-version-file: go.mod # honours the 'go' directive in go.mod
50+
cache: true
51+
52+
# ── 4. Build for all platforms ──────────────────────────────────
53+
- name: Build binaries
54+
env:
55+
VERSION: ${{ steps.version.outputs.tag }}
56+
LDFLAGS: -s -w -X main.version=${{ steps.version.outputs.tag }}
57+
run: |
58+
mkdir -p dist
59+
60+
echo "Building $VERSION …"
61+
62+
GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" \
63+
-o dist/androidbackup-windows-amd64.exe .
64+
65+
GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" \
66+
-o dist/androidbackup-linux-amd64 .
67+
68+
GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" \
69+
-o dist/androidbackup-linux-arm64 .
70+
71+
GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" \
72+
-o dist/androidbackup-darwin-amd64 .
73+
74+
GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" \
75+
-o dist/androidbackup-darwin-arm64 .
76+
77+
echo "Built artifacts:"
78+
ls -lh dist/
79+
80+
# ── 5. Collect commit messages for the release body ─────────────
81+
- name: Build changelog
82+
id: changelog
83+
run: |
84+
PREV=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
85+
if [ -z "$PREV" ]; then
86+
LOG=$(git log --oneline | head -20)
87+
else
88+
LOG=$(git log "${PREV}..HEAD" --oneline)
89+
fi
90+
# Escape for multiline GitHub output
91+
echo "log<<EOF" >> "$GITHUB_OUTPUT"
92+
echo "$LOG" >> "$GITHUB_OUTPUT"
93+
echo "EOF" >> "$GITHUB_OUTPUT"
94+
95+
# ── 6. Create GitHub release with binaries ──────────────────────
96+
- name: Create release
97+
uses: softprops/action-gh-release@v2
98+
with:
99+
tag_name: ${{ steps.version.outputs.tag }}
100+
name: ${{ steps.version.outputs.tag }}
101+
body: |
102+
## Changes
103+
```
104+
${{ steps.changelog.outputs.log }}
105+
```
106+
107+
## Downloads
108+
109+
| Platform | Architecture | Binary |
110+
|----------|--------------|--------|
111+
| Windows | x64 | `androidbackup-windows-amd64.exe` |
112+
| Linux | x64 | `androidbackup-linux-amd64` |
113+
| Linux | ARM64 | `androidbackup-linux-arm64` |
114+
| macOS | x64 | `androidbackup-darwin-amd64` |
115+
| macOS | Apple Silicon| `androidbackup-darwin-arm64` |
116+
117+
## Requirements
118+
- [ADB (Android Debug Bridge)](https://developer.android.com/tools/releases/platform-tools) must be installed and in your `PATH`
119+
120+
## Usage
121+
```
122+
# Windows
123+
.\androidbackup-windows-amd64.exe
124+
125+
# Linux / macOS (make executable first)
126+
chmod +x androidbackup-linux-amd64
127+
./androidbackup-linux-amd64
128+
```
129+
Opens at **http://localhost:8765**
130+
files: |
131+
dist/androidbackup-windows-amd64.exe
132+
dist/androidbackup-linux-amd64
133+
dist/androidbackup-linux-arm64
134+
dist/androidbackup-darwin-amd64
135+
dist/androidbackup-darwin-arm64
136+
draft: false
137+
prerelease: false
138+
make_latest: true

.gitignore

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
1-
/dist
1+
# Built binaries
2+
/dist/
3+
*.exe
4+
5+
# ADB view cache (temp files pulled for viewer)
6+
# (lives in %TEMP% on Windows, so not tracked anyway)
7+
8+
# Transfer state
9+
androidbackup_state.json

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Copy photos and files from your Android device to your PC via a web interface — with folder browsing, pause/resume transfers, file management, and Wi-Fi ADB support.
44

5+
![Android Backup screenshot](docs/screen-shot.png)
6+
57
---
68

79
## Prerequisites

adb.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package main
33
import (
44
"bufio"
55
"fmt"
6+
"os"
67
"os/exec"
8+
"path/filepath"
79
"strconv"
810
"strings"
911
"time"
@@ -225,8 +227,66 @@ func RenamePath(serial, oldPath, newPath string) error {
225227
return err
226228
}
227229

228-
// PullFile runs adb pull for a single file. Returns bytes transferred.
230+
// PullFile runs adb pull for a single file.
229231
func PullFile(serial, remotePath, localPath string) error {
230232
_, err := exec.Command("adb", "-s", serial, "pull", remotePath, localPath).CombinedOutput()
231233
return err
232234
}
235+
236+
// ViewCachePath returns the local cache path for a remote file preview.
237+
func ViewCachePath(serial, remotePath string) string {
238+
cacheDir := filepath.Join(os.TempDir(), "androidbackup_view")
239+
_ = os.MkdirAll(cacheDir, 0755)
240+
base := filepath.Base(remotePath)
241+
return filepath.Join(cacheDir, fmt.Sprintf("%08x_%s", fnvHash(serial+":"+remotePath), sanitizeFilename(base)))
242+
}
243+
244+
func fnvHash(s string) uint32 {
245+
h := uint32(2166136261)
246+
for i := 0; i < len(s); i++ {
247+
h ^= uint32(s[i])
248+
h *= 16777619
249+
}
250+
return h
251+
}
252+
253+
func sanitizeFilename(name string) string {
254+
var b strings.Builder
255+
for _, r := range name {
256+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' {
257+
b.WriteRune(r)
258+
} else {
259+
b.WriteRune('_')
260+
}
261+
}
262+
s := b.String()
263+
if len(s) > 80 {
264+
s = s[len(s)-80:]
265+
}
266+
return s
267+
}
268+
269+
// ExtContentType maps a lowercase file extension (without dot) to a MIME type.
270+
func ExtContentType(ext string) string {
271+
m := map[string]string{
272+
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
273+
"gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp",
274+
"heic": "image/heic", "heif": "image/heif",
275+
"mp4": "video/mp4", "m4v": "video/mp4", "mov": "video/quicktime",
276+
"mkv": "video/x-matroska", "avi": "video/x-msvideo", "3gp": "video/3gpp",
277+
"webm": "video/webm",
278+
"mp3": "audio/mpeg", "aac": "audio/aac", "m4a": "audio/mp4",
279+
"ogg": "audio/ogg", "flac": "audio/flac", "wav": "audio/wav",
280+
"pdf": "application/pdf",
281+
"txt": "text/plain; charset=utf-8",
282+
"log": "text/plain; charset=utf-8",
283+
"xml": "text/xml; charset=utf-8",
284+
"json": "application/json; charset=utf-8",
285+
"csv": "text/csv; charset=utf-8",
286+
"md": "text/plain; charset=utf-8",
287+
}
288+
if ct, ok := m[ext]; ok {
289+
return ct
290+
}
291+
return "application/octet-stream"
292+
}

docs/screen-shot.png

158 KB
Loading

handlers.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"net/http"
67
"os"
78
"path/filepath"
@@ -138,6 +139,58 @@ func handleLocalBrowse(w http.ResponseWriter, r *http.Request) {
138139
writeJSON(w, 200, result)
139140
}
140141

142+
func handleAndroidView(w http.ResponseWriter, r *http.Request) {
143+
serial := r.URL.Query().Get("serial")
144+
remotePath := r.URL.Query().Get("path")
145+
if serial == "" || remotePath == "" {
146+
errResp(w, 400, "serial and path required")
147+
return
148+
}
149+
150+
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(remotePath), "."))
151+
ct := ExtContentType(ext)
152+
153+
cachedPath := ViewCachePath(serial, remotePath)
154+
155+
// Re-pull when caller requests a refresh or file is not in cache
156+
if r.URL.Query().Get("refresh") == "1" {
157+
_ = os.Remove(cachedPath)
158+
}
159+
if _, err := os.Stat(cachedPath); os.IsNotExist(err) {
160+
if err := PullFile(serial, remotePath, cachedPath); err != nil {
161+
errResp(w, 502, "adb pull failed: "+err.Error())
162+
return
163+
}
164+
}
165+
166+
f, err := os.Open(cachedPath)
167+
if err != nil {
168+
errResp(w, 500, err.Error())
169+
return
170+
}
171+
defer f.Close()
172+
173+
stat, _ := f.Stat()
174+
w.Header().Set("Content-Type", ct)
175+
w.Header().Set("Cache-Control", "no-store")
176+
http.ServeContent(w, r, filepath.Base(remotePath), stat.ModTime(), f)
177+
}
178+
179+
func handleLocalMkdir(w http.ResponseWriter, r *http.Request) {
180+
var body struct {
181+
Path string `json:"path"` // full path of the new folder to create
182+
}
183+
if err := readBody(r, &body); err != nil || body.Path == "" {
184+
errResp(w, 400, "path required")
185+
return
186+
}
187+
if err := os.MkdirAll(body.Path, 0755); err != nil {
188+
errResp(w, 500, err.Error())
189+
return
190+
}
191+
writeJSON(w, 200, map[string]string{"path": body.Path})
192+
}
193+
141194
// ---- device handlers ----
142195

143196
func handleListDevices(w http.ResponseWriter, r *http.Request) {
@@ -245,7 +298,7 @@ func makeTransferHandlers(tm *TransferManager) func(mux *http.ServeMux) {
245298
mux.HandleFunc("/api/transfers", func(w http.ResponseWriter, r *http.Request) {
246299
switch r.Method {
247300
case http.MethodGet:
248-
writeJSON(w, 200, tm.List())
301+
writeJSON(w, 200, tm.ListSummaries())
249302
case http.MethodPost:
250303
var body struct {
251304
DeviceSerial string `json:"deviceSerial"`
@@ -313,6 +366,23 @@ func makeTransferHandlers(tm *TransferManager) func(mux *http.ServeMux) {
313366
return
314367
}
315368
writeJSON(w, 200, t)
369+
case "files":
370+
if r.Method != http.MethodGet {
371+
errResp(w, 405, "method not allowed")
372+
return
373+
}
374+
q := r.URL.Query()
375+
filter := q.Get("filter")
376+
offset := 0
377+
limit := 100
378+
fmt.Sscan(q.Get("offset"), &offset)
379+
fmt.Sscan(q.Get("limit"), &limit)
380+
result, err := tm.GetFiles(id, filter, offset, limit)
381+
if err != nil {
382+
errResp(w, 404, err.Error())
383+
return
384+
}
385+
writeJSON(w, 200, result)
316386
case "remove":
317387
if err := tm.Remove(id); err != nil {
318388
errResp(w, 400, err.Error())

0 commit comments

Comments
 (0)