Update upstream source from tag 'upstream/0.5.0'
Update to upstream version '0.5.0'
with Debian dir c600289a7c0ada3f320d63af50a9bc14270cabc3
Jochen Sprickerhof
5 years ago
0 | 0 | language: go |
1 | 1 | |
2 | 2 | go: |
3 | - 1.9.x | |
4 | - 1.10.x | |
3 | - 1.11.x | |
4 | - 1.12beta2 | |
5 | 5 | |
6 | go_import_path: mvdan.cc/fdroidcl | |
6 | env: | |
7 | - GO111MODULE=on | |
8 | ||
9 | install: true | |
10 | ||
11 | script: | |
12 | - go test ./... |
2 | 2 | [![GoDoc](https://godoc.org/github.com/mvdan/fdroidcl?status.svg)](https://godoc.org/mvdan.cc/fdroidcl) |
3 | 3 | [![Build Status](https://travis-ci.org/mvdan/fdroidcl.svg?branch=master)](https://travis-ci.org/mvdan/fdroidcl) |
4 | 4 | |
5 | [F-Droid](https://f-droid.org/) desktop client. | |
5 | [F-Droid](https://f-droid.org/) desktop client. Requires Go 1.11 or later. | |
6 | 6 | |
7 | go get -u mvdan.cc/fdroidcl/cmd/fdroidcl | |
7 | go get -u mvdan.cc/fdroidcl | |
8 | 8 | |
9 | 9 | While the Android client integrates with the system with regular update checks |
10 | 10 | and notifications, this is a simple command line client that talks to connected |
27 | 27 | ### Commands |
28 | 28 | |
29 | 29 | update Update the index |
30 | search <regexp...> Search available apps | |
30 | search [<regexp...>] Search available apps | |
31 | 31 | show <appid...> Show detailed info about an app |
32 | install [<appid...>] Install or upgrade apps | |
33 | uninstall <appid...> Uninstall an app | |
34 | download <appid...> Download an app | |
32 | 35 | devices List connected devices |
33 | download <appid...> Download an app | |
34 | install <appid...> Install or upgrade app | |
35 | uninstall <appid...> Uninstall an app | |
36 | list (categories) List all known values of a kind | |
36 | 37 | defaults Reset to the default settings |
38 | version Print version information | |
37 | 39 | |
38 | A specific version of an app can be selected by following the appid with an | |
39 | colon (:) and the version code of the app to select. | |
40 | ||
41 | An appid is just an app's unique package name. A specific version of an app can | |
42 | be selected by following the appid with a colon and the version code. The | |
43 | 'search' and 'show' commands can be used to find these strings. For example: | |
44 | ||
45 | $ fdroidcl search redreader | |
46 | $ fdroidcl show org.quantumbadger.redreader | |
47 | $ fdroidcl install org.quantumbadger.redreader:85 | |
40 | 48 | |
41 | 49 | ### Config |
42 | 50 |
8 | 8 | "path/filepath" |
9 | 9 | ) |
10 | 10 | |
11 | // Cache returns the base cache directory. | |
12 | func Cache() string { | |
13 | return cache() | |
14 | } | |
11 | // TODO: replace with https://github.com/golang/go/issues/29960 if accepted. | |
15 | 12 | |
16 | 13 | // Data returns the base data directory. |
17 | 14 | func Data() string { |
18 | return data() | |
15 | return dataDir | |
19 | 16 | } |
20 | 17 | |
21 | 18 | func firstGetenv(def string, evs ...string) string { |
24 | 21 | return v |
25 | 22 | } |
26 | 23 | } |
27 | home, err := homeDir() | |
28 | if err != nil { | |
29 | return "" | |
24 | // TODO: replace with os.UserHomeDir once we require Go 1.12 or later. | |
25 | home := os.Getenv("HOME") | |
26 | if home == "" { | |
27 | curUser, err := user.Current() | |
28 | if err != nil { | |
29 | return "" | |
30 | } | |
31 | home = curUser.HomeDir | |
30 | 32 | } |
31 | 33 | return filepath.Join(home, def) |
32 | 34 | } |
33 | ||
34 | func homeDir() (string, error) { | |
35 | curUser, err := user.Current() | |
36 | if err != nil { | |
37 | return "", err | |
38 | } | |
39 | return curUser.HomeDir, nil | |
40 | } |
2 | 2 | |
3 | 3 | package basedir |
4 | 4 | |
5 | var ( | |
6 | cacheDir = firstGetenv("Library/Caches") | |
7 | dataDir = firstGetenv("Library/Application Support") | |
8 | ) | |
9 | ||
10 | func cache() string { | |
11 | return cacheDir | |
12 | } | |
13 | ||
14 | func data() string { | |
15 | return dataDir | |
16 | } | |
5 | var dataDir = firstGetenv("Library/Application Support") |
4 | 4 | |
5 | 5 | package basedir |
6 | 6 | |
7 | var ( | |
8 | cacheDir = firstGetenv(".cache", "XDG_CACHE_HOME") | |
9 | dataDir = firstGetenv(".config", "XDG_CONFIG_HOME") | |
10 | ) | |
11 | ||
12 | func cache() string { | |
13 | return cacheDir | |
14 | } | |
15 | ||
16 | func data() string { | |
17 | return dataDir | |
18 | } | |
7 | var dataDir = firstGetenv(".config", "XDG_CONFIG_HOME") |
2 | 2 | |
3 | 3 | package basedir |
4 | 4 | |
5 | var ( | |
6 | cacheDir = firstGetenv("", "TEMP", "TMP") | |
7 | dataDir = firstGetenv("", "APPDATA") | |
8 | ) | |
9 | ||
10 | func cache() string { | |
11 | return cacheDir | |
12 | } | |
13 | ||
14 | func data() string { | |
15 | return dataDir | |
16 | } | |
5 | var dataDir = firstGetenv("", "APPDATA") |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "encoding/json" | |
7 | "fmt" | |
8 | "os" | |
9 | ) | |
10 | ||
11 | var cmdDefaults = &Command{ | |
12 | UsageLine: "defaults", | |
13 | Short: "Reset to the default settings", | |
14 | } | |
15 | ||
16 | func init() { | |
17 | cmdDefaults.Run = runDefaults | |
18 | } | |
19 | ||
20 | func runDefaults(args []string) error { | |
21 | if len(args) > 0 { | |
22 | return fmt.Errorf("no arguments allowed") | |
23 | } | |
24 | return writeConfig(&config) | |
25 | } | |
26 | ||
27 | func writeConfig(c *userConfig) error { | |
28 | b, err := json.MarshalIndent(c, "", "\t") | |
29 | if err != nil { | |
30 | return fmt.Errorf("cannot encode config: %v", err) | |
31 | } | |
32 | f, err := os.Create(configPath()) | |
33 | if err != nil { | |
34 | return fmt.Errorf("cannot create config file: %v", err) | |
35 | } | |
36 | _, err = f.Write(b) | |
37 | if cerr := f.Close(); err == nil { | |
38 | err = cerr | |
39 | } | |
40 | return err | |
41 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | ||
8 | "mvdan.cc/fdroidcl/adb" | |
9 | ) | |
10 | ||
11 | var cmdDevices = &Command{ | |
12 | UsageLine: "devices", | |
13 | Short: "List connected devices", | |
14 | } | |
15 | ||
16 | func init() { | |
17 | cmdDevices.Run = runDevices | |
18 | } | |
19 | ||
20 | func runDevices(args []string) error { | |
21 | if err := startAdbIfNeeded(); err != nil { | |
22 | return err | |
23 | } | |
24 | devices, err := adb.Devices() | |
25 | if err != nil { | |
26 | return fmt.Errorf("could not get devices: %v", err) | |
27 | } | |
28 | for _, device := range devices { | |
29 | fmt.Fprintf(stdout, "%s - %s (%s)\n", device.ID, device.Model, device.Product) | |
30 | } | |
31 | return nil | |
32 | } | |
33 | ||
34 | func startAdbIfNeeded() error { | |
35 | if adb.IsServerRunning() { | |
36 | return nil | |
37 | } | |
38 | if err := adb.StartServer(); err != nil { | |
39 | return fmt.Errorf("could not start ADB server: %v", err) | |
40 | } | |
41 | return nil | |
42 | } | |
43 | ||
44 | func maybeOneDevice() (*adb.Device, error) { | |
45 | if err := startAdbIfNeeded(); err != nil { | |
46 | return nil, err | |
47 | } | |
48 | devices, err := adb.Devices() | |
49 | if err != nil { | |
50 | return nil, fmt.Errorf("could not get devices: %v", err) | |
51 | } | |
52 | if len(devices) > 1 { | |
53 | return nil, fmt.Errorf("at most one connected device can be used") | |
54 | } | |
55 | if len(devices) < 1 { | |
56 | return nil, nil | |
57 | } | |
58 | return devices[0], nil | |
59 | } | |
60 | ||
61 | func oneDevice() (*adb.Device, error) { | |
62 | device, err := maybeOneDevice() | |
63 | if err == nil && device == nil { | |
64 | err = fmt.Errorf("a connected device is needed") | |
65 | } | |
66 | return device, err | |
67 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | "path/filepath" | |
8 | ||
9 | "mvdan.cc/fdroidcl" | |
10 | ) | |
11 | ||
12 | var cmdDownload = &Command{ | |
13 | UsageLine: "download <appid...>", | |
14 | Short: "Download an app", | |
15 | } | |
16 | ||
17 | func init() { | |
18 | cmdDownload.Run = runDownload | |
19 | } | |
20 | ||
21 | func runDownload(args []string) error { | |
22 | if len(args) < 1 { | |
23 | return fmt.Errorf("no package names given") | |
24 | } | |
25 | apps, err := findApps(args) | |
26 | if err != nil { | |
27 | return err | |
28 | } | |
29 | device, err := maybeOneDevice() | |
30 | if err != nil { | |
31 | return err | |
32 | } | |
33 | for _, app := range apps { | |
34 | apk := app.SuggestedApk(device) | |
35 | if apk == nil { | |
36 | return fmt.Errorf("no suggested APK found for %s", app.PackageName) | |
37 | } | |
38 | path, err := downloadApk(apk) | |
39 | if err != nil { | |
40 | return err | |
41 | } | |
42 | fmt.Fprintf(stdout, "APK available in %s\n", path) | |
43 | } | |
44 | return nil | |
45 | } | |
46 | ||
47 | func downloadApk(apk *fdroidcl.Apk) (string, error) { | |
48 | url := apk.URL() | |
49 | path := apkPath(apk.ApkName) | |
50 | if err := downloadEtag(url, path, apk.Hash); err == errNotModified { | |
51 | } else if err != nil { | |
52 | return "", fmt.Errorf("could not download %s: %v", apk.AppID, err) | |
53 | } | |
54 | return path, nil | |
55 | } | |
56 | ||
57 | func apkPath(apkname string) string { | |
58 | apksDir := subdir(mustCache(), "apks") | |
59 | return filepath.Join(apksDir, apkname) | |
60 | } |
0 | // Copyright (c) 2018, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "bytes" | |
7 | "io/ioutil" | |
8 | "net/http" | |
9 | "os" | |
10 | "regexp" | |
11 | "testing" | |
12 | "time" | |
13 | ||
14 | "mvdan.cc/fdroidcl/adb" | |
15 | ) | |
16 | ||
17 | // chosenApp is the app that will be installed and uninstalled on a connected | |
18 | // device. This one was chosen because it's tiny, requires no permissions, and | |
19 | // should be compatible with every device. | |
20 | // | |
21 | // It also stores no data, so it is fine to uninstall it and the user won't lose | |
22 | // any data. | |
23 | const chosenApp = "org.vi_server.red_screen" | |
24 | ||
25 | func TestCommands(t *testing.T) { | |
26 | url := config.Repos[0].URL | |
27 | client := http.Client{Timeout: 2 * time.Second} | |
28 | if _, err := client.Get(url); err != nil { | |
29 | t.Skipf("skipping since %s is unreachable: %v", url, err) | |
30 | } | |
31 | ||
32 | dir, err := ioutil.TempDir("", "fdroidcl") | |
33 | if err != nil { | |
34 | t.Fatal(err) | |
35 | } | |
36 | defer os.RemoveAll(dir) | |
37 | testBasedir = dir | |
38 | ||
39 | mustSucceed := func(t *testing.T, wantRe, negRe string, cmd *Command, args ...string) { | |
40 | mustRun(t, true, wantRe, negRe, cmd, args...) | |
41 | } | |
42 | mustFail := func(t *testing.T, wantRe, negRe string, cmd *Command, args ...string) { | |
43 | mustRun(t, false, wantRe, negRe, cmd, args...) | |
44 | } | |
45 | ||
46 | t.Run("Version", func(t *testing.T) { | |
47 | mustSucceed(t, `^v`, ``, cmdVersion) | |
48 | }) | |
49 | ||
50 | t.Run("SearchBeforeUpdate", func(t *testing.T) { | |
51 | mustFail(t, `could not open index`, ``, cmdSearch) | |
52 | }) | |
53 | t.Run("UpdateFirst", func(t *testing.T) { | |
54 | mustSucceed(t, `done`, ``, cmdUpdate) | |
55 | }) | |
56 | t.Run("UpdateCached", func(t *testing.T) { | |
57 | mustSucceed(t, `not modified`, ``, cmdUpdate) | |
58 | }) | |
59 | ||
60 | t.Run("SearchNoArgs", func(t *testing.T) { | |
61 | mustSucceed(t, `F-Droid`, ``, cmdSearch) | |
62 | }) | |
63 | t.Run("SearchWithArgs", func(t *testing.T) { | |
64 | mustSucceed(t, `F-Droid`, ``, cmdSearch, "fdroid.fdroid") | |
65 | }) | |
66 | t.Run("SearchWithArgsNone", func(t *testing.T) { | |
67 | mustSucceed(t, `^$`, ``, cmdSearch, "nomatches") | |
68 | }) | |
69 | t.Run("SearchOnlyPackageNames", func(t *testing.T) { | |
70 | mustSucceed(t, `^[^ ]*$`, ``, cmdSearch, "-q", "fdroid.fdroid") | |
71 | }) | |
72 | ||
73 | t.Run("ShowOne", func(t *testing.T) { | |
74 | mustSucceed(t, `fdroid/fdroidclient`, ``, cmdShow, "org.fdroid.fdroid") | |
75 | }) | |
76 | t.Run("ShowMany", func(t *testing.T) { | |
77 | mustSucceed(t, `fdroid/fdroidclient.*fdroid/privileged-extension`, ``, | |
78 | cmdShow, "org.fdroid.fdroid", "org.fdroid.fdroid.privileged") | |
79 | }) | |
80 | ||
81 | t.Run("ListCategories", func(t *testing.T) { | |
82 | mustSucceed(t, `Development`, ``, cmdList, "categories") | |
83 | }) | |
84 | ||
85 | if err := startAdbIfNeeded(); err != nil { | |
86 | t.Log("skipping the device tests as ADB is not installed") | |
87 | return | |
88 | } | |
89 | devices, err := adb.Devices() | |
90 | if err != nil { | |
91 | t.Fatal(err) | |
92 | } | |
93 | switch len(devices) { | |
94 | case 0: | |
95 | t.Log("skipping the device tests as none was found via ADB") | |
96 | return | |
97 | case 1: | |
98 | // continue below | |
99 | default: | |
100 | t.Log("skipping the device tests as too many were found via ADB") | |
101 | return | |
102 | } | |
103 | ||
104 | t.Run("DevicesOne", func(t *testing.T) { | |
105 | mustSucceed(t, `\n`, ``, cmdDevices) | |
106 | }) | |
107 | ||
108 | // try to uninstall the app first | |
109 | devices[0].Uninstall(chosenApp) | |
110 | t.Run("UninstallMissing", func(t *testing.T) { | |
111 | mustFail(t, `not installed$`, ``, cmdUninstall, chosenApp) | |
112 | }) | |
113 | t.Run("SearchInstalledMissing", func(t *testing.T) { | |
114 | mustSucceed(t, ``, regexp.QuoteMeta(chosenApp), cmdSearch, "-i", "-q") | |
115 | }) | |
116 | t.Run("SearchUpgradableMissing", func(t *testing.T) { | |
117 | mustSucceed(t, ``, regexp.QuoteMeta(chosenApp), cmdSearch, "-u", "-q") | |
118 | }) | |
119 | t.Run("InstallVersioned", func(t *testing.T) { | |
120 | mustSucceed(t, `Installing `+regexp.QuoteMeta(chosenApp), ``, | |
121 | cmdInstall, chosenApp+":1") | |
122 | }) | |
123 | t.Run("SearchInstalled", func(t *testing.T) { | |
124 | time.Sleep(3 * time.Second) | |
125 | mustSucceed(t, regexp.QuoteMeta(chosenApp), ``, cmdSearch, "-i", "-q") | |
126 | }) | |
127 | t.Run("SearchUpgradable", func(t *testing.T) { | |
128 | mustSucceed(t, regexp.QuoteMeta(chosenApp), ``, cmdSearch, "-u", "-q") | |
129 | }) | |
130 | t.Run("InstallUpgrade", func(t *testing.T) { | |
131 | mustSucceed(t, `Installing `+regexp.QuoteMeta(chosenApp), ``, | |
132 | cmdInstall, chosenApp) | |
133 | }) | |
134 | t.Run("SearchUpgradableUpToDate", func(t *testing.T) { | |
135 | mustSucceed(t, ``, regexp.QuoteMeta(chosenApp), cmdSearch, "-u", "-q") | |
136 | }) | |
137 | t.Run("InstallUpToDate", func(t *testing.T) { | |
138 | mustSucceed(t, `is up to date$`, ``, cmdInstall, chosenApp) | |
139 | }) | |
140 | t.Run("UninstallExisting", func(t *testing.T) { | |
141 | mustSucceed(t, `Uninstalling `+regexp.QuoteMeta(chosenApp), ``, | |
142 | cmdUninstall, chosenApp) | |
143 | }) | |
144 | } | |
145 | ||
146 | func mustRun(t *testing.T, success bool, wantRe, negRe string, cmd *Command, args ...string) { | |
147 | var buf bytes.Buffer | |
148 | stdout, stderr = &buf, &buf | |
149 | err := cmd.Run(args) | |
150 | out := buf.String() | |
151 | if err != nil { | |
152 | out += err.Error() | |
153 | } | |
154 | if success && err != nil { | |
155 | t.Fatalf("unexpected error: %v\n%s", err, out) | |
156 | } else if !success && err == nil { | |
157 | t.Fatalf("expected error, got none\n%s", out) | |
158 | } | |
159 | // Let '.' match newlines, and treat the output as a single line. | |
160 | wantRe = "(?sm)" + wantRe | |
161 | if !regexp.MustCompile(wantRe).MatchString(out) { | |
162 | t.Fatalf("output does not match %#q:\n%s", wantRe, out) | |
163 | } | |
164 | if negRe != "" { | |
165 | negRe = "(?sm)" + negRe | |
166 | if regexp.MustCompile(negRe).MatchString(out) { | |
167 | t.Fatalf("output does match %#q:\n%s", negRe, out) | |
168 | } | |
169 | } | |
170 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | ||
8 | "mvdan.cc/fdroidcl" | |
9 | "mvdan.cc/fdroidcl/adb" | |
10 | ) | |
11 | ||
12 | var cmdInstall = &Command{ | |
13 | UsageLine: "install <appid...>", | |
14 | Short: "Install or upgrade an app", | |
15 | } | |
16 | ||
17 | func init() { | |
18 | cmdInstall.Run = runInstall | |
19 | } | |
20 | ||
21 | func runInstall(args []string) error { | |
22 | if len(args) < 1 { | |
23 | return fmt.Errorf("no package names given") | |
24 | } | |
25 | device, err := oneDevice() | |
26 | if err != nil { | |
27 | return err | |
28 | } | |
29 | apps, err := findApps(args) | |
30 | if err != nil { | |
31 | return err | |
32 | } | |
33 | inst, err := device.Installed() | |
34 | if err != nil { | |
35 | return err | |
36 | } | |
37 | var toInstall []*fdroidcl.App | |
38 | for _, app := range apps { | |
39 | p, e := inst[app.PackageName] | |
40 | if !e { | |
41 | // installing an app from scratch | |
42 | toInstall = append(toInstall, app) | |
43 | continue | |
44 | } | |
45 | suggested := app.SuggestedApk(device) | |
46 | if suggested == nil { | |
47 | return fmt.Errorf("no suitable APKs found for %s", app.PackageName) | |
48 | } | |
49 | if p.VersCode >= suggested.VersCode { | |
50 | fmt.Fprintf(stdout, "%s is up to date\n", app.PackageName) | |
51 | // app is already up to date | |
52 | continue | |
53 | } | |
54 | // upgrading an existing app | |
55 | toInstall = append(toInstall, app) | |
56 | } | |
57 | return downloadAndDo(toInstall, device) | |
58 | } | |
59 | ||
60 | func downloadAndDo(apps []*fdroidcl.App, device *adb.Device) error { | |
61 | type downloaded struct { | |
62 | apk *fdroidcl.Apk | |
63 | path string | |
64 | } | |
65 | toInstall := make([]downloaded, len(apps)) | |
66 | for i, app := range apps { | |
67 | apk := app.SuggestedApk(device) | |
68 | if apk == nil { | |
69 | return fmt.Errorf("no suitable APKs found for %s", app.PackageName) | |
70 | } | |
71 | path, err := downloadApk(apk) | |
72 | if err != nil { | |
73 | return err | |
74 | } | |
75 | toInstall[i] = downloaded{apk: apk, path: path} | |
76 | } | |
77 | for _, t := range toInstall { | |
78 | if err := installApk(device, t.apk, t.path); err != nil { | |
79 | return err | |
80 | } | |
81 | } | |
82 | return nil | |
83 | } | |
84 | ||
85 | func installApk(device *adb.Device, apk *fdroidcl.Apk, path string) error { | |
86 | fmt.Fprintf(stdout, "Installing %s\n", apk.AppID) | |
87 | if err := device.Install(path); err != nil { | |
88 | return fmt.Errorf("could not install %s: %v", apk.AppID, err) | |
89 | } | |
90 | return nil | |
91 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | "sort" | |
8 | ) | |
9 | ||
10 | var cmdList = &Command{ | |
11 | UsageLine: "list (categories)", | |
12 | Short: "List all known values of a kind", | |
13 | } | |
14 | ||
15 | func init() { | |
16 | cmdList.Run = runList | |
17 | } | |
18 | ||
19 | func runList(args []string) error { | |
20 | if len(args) != 1 { | |
21 | return fmt.Errorf("need exactly one argument") | |
22 | } | |
23 | apps, err := loadIndexes() | |
24 | if err != nil { | |
25 | return err | |
26 | } | |
27 | values := make(map[string]struct{}) | |
28 | switch args[0] { | |
29 | case "categories": | |
30 | for _, app := range apps { | |
31 | for _, c := range app.Categories { | |
32 | values[c] = struct{}{} | |
33 | } | |
34 | } | |
35 | default: | |
36 | return fmt.Errorf("invalid argument") | |
37 | } | |
38 | result := make([]string, 0, len(values)) | |
39 | for s := range values { | |
40 | result = append(result, s) | |
41 | } | |
42 | sort.Strings(result) | |
43 | for _, s := range result { | |
44 | fmt.Fprintln(stdout, s) | |
45 | } | |
46 | return nil | |
47 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "encoding/json" | |
7 | "flag" | |
8 | "fmt" | |
9 | "io" | |
10 | "os" | |
11 | "path/filepath" | |
12 | "strings" | |
13 | ||
14 | "mvdan.cc/fdroidcl/basedir" | |
15 | ) | |
16 | ||
17 | const cmdName = "fdroidcl" | |
18 | ||
19 | const version = "v0.4.0" | |
20 | ||
21 | func errExit(format string, a ...interface{}) { | |
22 | fmt.Fprintf(stderr, format, a...) | |
23 | os.Exit(1) | |
24 | } | |
25 | ||
26 | func subdir(dir, name string) string { | |
27 | p := filepath.Join(dir, name) | |
28 | if err := os.MkdirAll(p, 0755); err != nil { | |
29 | errExit("Could not create dir '%s': %v\n", p, err) | |
30 | } | |
31 | return p | |
32 | } | |
33 | ||
34 | var ( | |
35 | stdout io.Writer = os.Stdout | |
36 | stderr io.Writer = os.Stderr | |
37 | ||
38 | testBasedir = "" | |
39 | ) | |
40 | ||
41 | func mustCache() string { | |
42 | if testBasedir != "" { | |
43 | return subdir(testBasedir, "cache") | |
44 | } | |
45 | dir := basedir.Cache() | |
46 | if dir == "" { | |
47 | errExit("Could not determine cache dir\n") | |
48 | } | |
49 | return subdir(dir, cmdName) | |
50 | } | |
51 | ||
52 | func mustData() string { | |
53 | if testBasedir != "" { | |
54 | return subdir(testBasedir, "data") | |
55 | } | |
56 | dir := basedir.Data() | |
57 | if dir == "" { | |
58 | errExit("Could not determine data dir\n") | |
59 | } | |
60 | return subdir(dir, cmdName) | |
61 | } | |
62 | ||
63 | func configPath() string { | |
64 | return filepath.Join(mustData(), "config.json") | |
65 | } | |
66 | ||
67 | type repo struct { | |
68 | ID string `json:"id"` | |
69 | URL string `json:"url"` | |
70 | Enabled bool `json:"enabled"` | |
71 | } | |
72 | ||
73 | type userConfig struct { | |
74 | Repos []repo `json:"repos"` | |
75 | } | |
76 | ||
77 | var config = userConfig{ | |
78 | Repos: []repo{ | |
79 | { | |
80 | ID: "f-droid", | |
81 | URL: "https://f-droid.org/repo", | |
82 | Enabled: true, | |
83 | }, | |
84 | { | |
85 | ID: "f-droid-archive", | |
86 | URL: "https://f-droid.org/archive", | |
87 | Enabled: false, | |
88 | }, | |
89 | }, | |
90 | } | |
91 | ||
92 | func readConfig() { | |
93 | f, err := os.Open(configPath()) | |
94 | if err != nil { | |
95 | return | |
96 | } | |
97 | defer f.Close() | |
98 | fileConfig := userConfig{} | |
99 | if err := json.NewDecoder(f).Decode(&fileConfig); err == nil { | |
100 | config = fileConfig | |
101 | } | |
102 | } | |
103 | ||
104 | // A Command is an implementation of a go command | |
105 | // like go build or go fix. | |
106 | type Command struct { | |
107 | // Run runs the command. | |
108 | // The args are the arguments after the command name. | |
109 | Run func(args []string) error | |
110 | ||
111 | // UsageLine is the one-line usage message. | |
112 | // The first word in the line is taken to be the command name. | |
113 | UsageLine string | |
114 | ||
115 | // Short is the short description. | |
116 | Short string | |
117 | } | |
118 | ||
119 | // Name returns the command's name: the first word in the usage line. | |
120 | func (c *Command) Name() string { | |
121 | name := c.UsageLine | |
122 | i := strings.Index(name, " ") | |
123 | if i >= 0 { | |
124 | name = name[:i] | |
125 | } | |
126 | return name | |
127 | } | |
128 | ||
129 | func (c *Command) usage(flagSet *flag.FlagSet) { | |
130 | fmt.Fprintf(stderr, "Usage: %s %s [-h]\n", cmdName, c.UsageLine) | |
131 | anyFlags := false | |
132 | flagSet.VisitAll(func(f *flag.Flag) { anyFlags = true }) | |
133 | if anyFlags { | |
134 | fmt.Fprintf(stderr, "\nAvailable options:\n") | |
135 | flagSet.PrintDefaults() | |
136 | } | |
137 | os.Exit(2) | |
138 | } | |
139 | ||
140 | func init() { | |
141 | flag.Usage = func() { | |
142 | fmt.Fprintf(stderr, "Usage: %s [-h] <command> [<args>]\n\n", cmdName) | |
143 | fmt.Fprintf(stderr, "Available commands:\n") | |
144 | maxUsageLen := 0 | |
145 | for _, c := range commands { | |
146 | if len(c.UsageLine) > maxUsageLen { | |
147 | maxUsageLen = len(c.UsageLine) | |
148 | } | |
149 | } | |
150 | for _, c := range commands { | |
151 | fmt.Fprintf(stderr, " %s%s %s\n", c.UsageLine, | |
152 | strings.Repeat(" ", maxUsageLen-len(c.UsageLine)), c.Short) | |
153 | } | |
154 | fmt.Fprintf(stderr, "\nA specific version of an app can be selected by following the appid with an colon (:) and the version code of the app to select.\n") | |
155 | fmt.Fprintf(stderr, "\nUse %s <command> -h for more info\n", cmdName) | |
156 | } | |
157 | } | |
158 | ||
159 | // Commands lists the available commands. | |
160 | var commands = []*Command{ | |
161 | cmdUpdate, | |
162 | cmdSearch, | |
163 | cmdShow, | |
164 | cmdInstall, | |
165 | cmdUninstall, | |
166 | cmdDownload, | |
167 | cmdDevices, | |
168 | cmdList, | |
169 | cmdDefaults, | |
170 | cmdVersion, | |
171 | } | |
172 | ||
173 | var cmdVersion = &Command{ | |
174 | UsageLine: "version", | |
175 | Short: "Print version information", | |
176 | Run: func(args []string) error { | |
177 | if len(args) > 0 { | |
178 | return fmt.Errorf("no arguments allowed") | |
179 | } | |
180 | fmt.Fprintln(stdout, version) | |
181 | return nil | |
182 | }, | |
183 | } | |
184 | ||
185 | func main() { | |
186 | flag.Parse() | |
187 | args := flag.Args() | |
188 | ||
189 | if len(args) < 1 { | |
190 | flag.Usage() | |
191 | os.Exit(2) | |
192 | } | |
193 | ||
194 | cmdName := args[0] | |
195 | for _, cmd := range commands { | |
196 | if cmd.Name() != cmdName { | |
197 | continue | |
198 | } | |
199 | readConfig() | |
200 | if err := cmd.Run(args[1:]); err != nil { | |
201 | errExit("%s: %v\n", cmdName, err) | |
202 | } | |
203 | return | |
204 | } | |
205 | ||
206 | switch cmdName { | |
207 | default: | |
208 | fmt.Fprintf(stderr, "Unrecognised command '%s'\n\n", cmdName) | |
209 | flag.Usage() | |
210 | os.Exit(2) | |
211 | } | |
212 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "flag" | |
7 | "fmt" | |
8 | "regexp" | |
9 | "sort" | |
10 | "strings" | |
11 | "time" | |
12 | ||
13 | "mvdan.cc/fdroidcl" | |
14 | "mvdan.cc/fdroidcl/adb" | |
15 | ) | |
16 | ||
17 | var cmdSearch = &Command{ | |
18 | UsageLine: "search [<regexp...>]", | |
19 | Short: "Search available apps", | |
20 | } | |
21 | ||
22 | func init() { | |
23 | cmdSearch.Run = runSearch | |
24 | } | |
25 | ||
26 | func runSearch(args []string) error { | |
27 | var fset flag.FlagSet | |
28 | var ( | |
29 | quiet = fset.Bool("q", false, "Print package names only") | |
30 | installed = fset.Bool("i", false, "Filter installed apps") | |
31 | updates = fset.Bool("u", false, "Filter apps with updates") | |
32 | days = fset.Int("d", 0, "Select apps last updated in the last <n> days; a negative value drops them instead") | |
33 | category = fset.String("c", "", "Filter apps by category") | |
34 | sortBy = fset.String("o", "", "Sort order (added, updated)") | |
35 | ) | |
36 | fset.Parse(args) | |
37 | args = fset.Args() | |
38 | if *installed && *updates { | |
39 | return fmt.Errorf("-i is redundant if -u is specified") | |
40 | } | |
41 | sfunc, err := sortFunc(*sortBy) | |
42 | if err != nil { | |
43 | return err | |
44 | } | |
45 | apps, err := loadIndexes() | |
46 | if err != nil { | |
47 | return err | |
48 | } | |
49 | if len(apps) > 0 && *category != "" { | |
50 | apps = filterAppsCategory(apps, *category) | |
51 | if apps == nil { | |
52 | return fmt.Errorf("no such category: %s", *category) | |
53 | } | |
54 | } | |
55 | if len(apps) > 0 && len(args) > 0 { | |
56 | apps = filterAppsSearch(apps, args) | |
57 | } | |
58 | var device *adb.Device | |
59 | var inst map[string]adb.Package | |
60 | if *installed || *updates { | |
61 | if device, err = oneDevice(); err != nil { | |
62 | return err | |
63 | } | |
64 | if inst, err = device.Installed(); err != nil { | |
65 | return err | |
66 | } | |
67 | } | |
68 | if len(apps) > 0 && *installed { | |
69 | apps = filterAppsInstalled(apps, inst) | |
70 | } | |
71 | if len(apps) > 0 && *updates { | |
72 | apps = filterAppsUpdates(apps, inst, device) | |
73 | } | |
74 | if len(apps) > 0 && *days != 0 { | |
75 | apps = filterAppsLastUpdated(apps, *days) | |
76 | } | |
77 | if sfunc != nil { | |
78 | apps = sortApps(apps, sfunc) | |
79 | } | |
80 | if *quiet { | |
81 | for _, app := range apps { | |
82 | fmt.Fprintln(stdout, app.PackageName) | |
83 | } | |
84 | } else { | |
85 | printApps(apps, inst, device) | |
86 | } | |
87 | return nil | |
88 | } | |
89 | ||
90 | func filterAppsSearch(apps []fdroidcl.App, terms []string) []fdroidcl.App { | |
91 | regexes := make([]*regexp.Regexp, len(terms)) | |
92 | for i, term := range terms { | |
93 | regexes[i] = regexp.MustCompile(term) | |
94 | } | |
95 | var result []fdroidcl.App | |
96 | for _, app := range apps { | |
97 | fields := []string{ | |
98 | strings.ToLower(app.PackageName), | |
99 | strings.ToLower(app.Name), | |
100 | strings.ToLower(app.Summary), | |
101 | strings.ToLower(app.Description), | |
102 | } | |
103 | if !appMatches(fields, regexes) { | |
104 | continue | |
105 | } | |
106 | result = append(result, app) | |
107 | } | |
108 | return result | |
109 | } | |
110 | ||
111 | func appMatches(fields []string, regexes []*regexp.Regexp) bool { | |
112 | fieldLoop: | |
113 | for _, field := range fields { | |
114 | for _, regex := range regexes { | |
115 | if !regex.MatchString(field) { | |
116 | continue fieldLoop | |
117 | } | |
118 | } | |
119 | return true | |
120 | } | |
121 | return false | |
122 | } | |
123 | ||
124 | func printApps(apps []fdroidcl.App, inst map[string]adb.Package, device *adb.Device) { | |
125 | maxIDLen := 0 | |
126 | for _, app := range apps { | |
127 | if len(app.PackageName) > maxIDLen { | |
128 | maxIDLen = len(app.PackageName) | |
129 | } | |
130 | } | |
131 | for _, app := range apps { | |
132 | var pkg *adb.Package | |
133 | p, e := inst[app.PackageName] | |
134 | if e { | |
135 | pkg = &p | |
136 | } | |
137 | printApp(app, maxIDLen, pkg, device) | |
138 | } | |
139 | } | |
140 | ||
141 | func descVersion(app fdroidcl.App, inst *adb.Package, device *adb.Device) string { | |
142 | if inst != nil { | |
143 | suggested := app.SuggestedApk(device) | |
144 | if suggested != nil && inst.VersCode < suggested.VersCode { | |
145 | return fmt.Sprintf("%s (%d) -> %s (%d)", inst.VersName, inst.VersCode, | |
146 | suggested.VersName, suggested.VersCode) | |
147 | } | |
148 | return fmt.Sprintf("%s (%d)", inst.VersName, inst.VersCode) | |
149 | } | |
150 | return fmt.Sprintf("%s (%d)", app.SugVersName, app.SugVersCode) | |
151 | } | |
152 | ||
153 | func printApp(app fdroidcl.App, IDLen int, inst *adb.Package, device *adb.Device) { | |
154 | fmt.Fprintf(stdout, "%s%s %s - %s\n", app.PackageName, strings.Repeat(" ", IDLen-len(app.PackageName)), | |
155 | app.Name, descVersion(app, inst, device)) | |
156 | fmt.Fprintf(stdout, " %s\n", app.Summary) | |
157 | } | |
158 | ||
159 | func filterAppsInstalled(apps []fdroidcl.App, inst map[string]adb.Package) []fdroidcl.App { | |
160 | var result []fdroidcl.App | |
161 | for _, app := range apps { | |
162 | if _, e := inst[app.PackageName]; !e { | |
163 | continue | |
164 | } | |
165 | result = append(result, app) | |
166 | } | |
167 | return result | |
168 | } | |
169 | ||
170 | func filterAppsUpdates(apps []fdroidcl.App, inst map[string]adb.Package, device *adb.Device) []fdroidcl.App { | |
171 | var result []fdroidcl.App | |
172 | for _, app := range apps { | |
173 | p, e := inst[app.PackageName] | |
174 | if !e { | |
175 | continue | |
176 | } | |
177 | suggested := app.SuggestedApk(device) | |
178 | if suggested == nil { | |
179 | continue | |
180 | } | |
181 | if p.VersCode >= suggested.VersCode { | |
182 | continue | |
183 | } | |
184 | result = append(result, app) | |
185 | } | |
186 | return result | |
187 | } | |
188 | ||
189 | func filterAppsLastUpdated(apps []fdroidcl.App, days int) []fdroidcl.App { | |
190 | var result []fdroidcl.App | |
191 | newer := true | |
192 | if days < 0 { | |
193 | days = -days | |
194 | newer = false | |
195 | } | |
196 | date := time.Now().Truncate(24*time.Hour).AddDate(0, 0, 1-days) | |
197 | for _, app := range apps { | |
198 | if app.Updated.Before(date) == newer { | |
199 | continue | |
200 | } | |
201 | result = append(result, app) | |
202 | } | |
203 | return result | |
204 | } | |
205 | ||
206 | func contains(l []string, s string) bool { | |
207 | for _, s1 := range l { | |
208 | if s1 == s { | |
209 | return true | |
210 | } | |
211 | } | |
212 | return false | |
213 | } | |
214 | ||
215 | func filterAppsCategory(apps []fdroidcl.App, categ string) []fdroidcl.App { | |
216 | var result []fdroidcl.App | |
217 | for _, app := range apps { | |
218 | if !contains(app.Categories, categ) { | |
219 | continue | |
220 | } | |
221 | result = append(result, app) | |
222 | } | |
223 | return result | |
224 | } | |
225 | ||
226 | func cmpAdded(a, b *fdroidcl.App) bool { | |
227 | return a.Added.Before(b.Added.Time) | |
228 | } | |
229 | ||
230 | func cmpUpdated(a, b *fdroidcl.App) bool { | |
231 | return a.Updated.Before(b.Updated.Time) | |
232 | } | |
233 | ||
234 | func sortFunc(sortBy string) (func(a, b *fdroidcl.App) bool, error) { | |
235 | switch sortBy { | |
236 | case "added": | |
237 | return cmpAdded, nil | |
238 | case "updated": | |
239 | return cmpUpdated, nil | |
240 | case "": | |
241 | return nil, nil | |
242 | } | |
243 | return nil, fmt.Errorf("unknown sort order: %s", sortBy) | |
244 | } | |
245 | ||
246 | type appList struct { | |
247 | l []fdroidcl.App | |
248 | f func(a, b *fdroidcl.App) bool | |
249 | } | |
250 | ||
251 | func (al appList) Len() int { return len(al.l) } | |
252 | func (al appList) Swap(i, j int) { al.l[i], al.l[j] = al.l[j], al.l[i] } | |
253 | func (al appList) Less(i, j int) bool { return al.f(&al.l[i], &al.l[j]) } | |
254 | ||
255 | func sortApps(apps []fdroidcl.App, f func(a, b *fdroidcl.App) bool) []fdroidcl.App { | |
256 | sort.Sort(appList{l: apps, f: f}) | |
257 | return apps | |
258 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | "strconv" | |
8 | "strings" | |
9 | ||
10 | "mvdan.cc/fdroidcl" | |
11 | ) | |
12 | ||
13 | var cmdShow = &Command{ | |
14 | UsageLine: "show <appid...>", | |
15 | Short: "Show detailed info about an app", | |
16 | } | |
17 | ||
18 | func init() { | |
19 | cmdShow.Run = runShow | |
20 | } | |
21 | ||
22 | func runShow(args []string) error { | |
23 | if len(args) < 1 { | |
24 | return fmt.Errorf("no package names given") | |
25 | } | |
26 | apps, err := findApps(args) | |
27 | if err != nil { | |
28 | return err | |
29 | } | |
30 | for i, app := range apps { | |
31 | if i > 0 { | |
32 | fmt.Fprintf(stdout, "\n--\n\n") | |
33 | } | |
34 | printAppDetailed(*app) | |
35 | } | |
36 | return nil | |
37 | } | |
38 | ||
39 | func appsMap(apps []fdroidcl.App) map[string]*fdroidcl.App { | |
40 | m := make(map[string]*fdroidcl.App, len(apps)) | |
41 | for i := range apps { | |
42 | app := &apps[i] | |
43 | m[app.PackageName] = app | |
44 | } | |
45 | return m | |
46 | } | |
47 | ||
48 | func findApps(ids []string) ([]*fdroidcl.App, error) { | |
49 | apps, err := loadIndexes() | |
50 | if err != nil { | |
51 | return nil, err | |
52 | } | |
53 | byId := appsMap(apps) | |
54 | result := make([]*fdroidcl.App, len(ids)) | |
55 | for i, id := range ids { | |
56 | var vcode = -1 | |
57 | j := strings.Index(id, ":") | |
58 | if j > -1 { | |
59 | var err error | |
60 | vcode, err = strconv.Atoi(id[j+1:]) | |
61 | if err != nil { | |
62 | return nil, fmt.Errorf("could not parse version code from '%s'", id) | |
63 | } | |
64 | id = id[:j] | |
65 | } | |
66 | ||
67 | app, e := byId[id] | |
68 | if !e { | |
69 | return nil, fmt.Errorf("could not find app with ID '%s'", id) | |
70 | } | |
71 | ||
72 | if vcode > -1 { | |
73 | found := false | |
74 | for _, apk := range app.Apks { | |
75 | if apk.VersCode == vcode { | |
76 | app.Apks = []*fdroidcl.Apk{apk} | |
77 | found = true | |
78 | } | |
79 | } | |
80 | if !found { | |
81 | return nil, fmt.Errorf("could not find version %d for app with ID '%s'", vcode, id) | |
82 | } | |
83 | } | |
84 | result[i] = app | |
85 | } | |
86 | return result, nil | |
87 | } | |
88 | ||
89 | func printAppDetailed(app fdroidcl.App) { | |
90 | p := func(title string, format string, args ...interface{}) { | |
91 | if format == "" { | |
92 | fmt.Fprintln(stdout, title) | |
93 | } else { | |
94 | fmt.Fprintf(stdout, "%s %s\n", title, fmt.Sprintf(format, args...)) | |
95 | } | |
96 | } | |
97 | p("Package :", "%s", app.PackageName) | |
98 | p("Name :", "%s", app.Name) | |
99 | p("Summary :", "%s", app.Summary) | |
100 | p("Added :", "%s", app.Added.String()) | |
101 | p("Last Updated :", "%s", app.Updated.String()) | |
102 | p("Version :", "%s (%d)", app.SugVersName, app.SugVersCode) | |
103 | p("License :", "%s", app.License) | |
104 | if app.Categories != nil { | |
105 | p("Categories :", "%s", strings.Join(app.Categories, ", ")) | |
106 | } | |
107 | if app.Website != "" { | |
108 | p("Website :", "%s", app.Website) | |
109 | } | |
110 | if app.SourceCode != "" { | |
111 | p("Source Code :", "%s", app.SourceCode) | |
112 | } | |
113 | if app.IssueTracker != "" { | |
114 | p("Issue Tracker :", "%s", app.IssueTracker) | |
115 | } | |
116 | if app.Changelog != "" { | |
117 | p("Changelog :", "%s", app.Changelog) | |
118 | } | |
119 | if app.Donate != "" { | |
120 | p("Donate :", "%s", app.Donate) | |
121 | } | |
122 | if app.Bitcoin != "" { | |
123 | p("Bitcoin :", "bitcoin:%s", app.Bitcoin) | |
124 | } | |
125 | if app.Litecoin != "" { | |
126 | p("Litecoin :", "litecoin:%s", app.Litecoin) | |
127 | } | |
128 | if app.FlattrID != "" { | |
129 | p("Flattr :", "https://flattr.com/thing/%s", app.FlattrID) | |
130 | } | |
131 | fmt.Fprintln(stdout) | |
132 | p("Description :", "") | |
133 | fmt.Fprintln(stdout) | |
134 | app.TextDesc(stdout) | |
135 | fmt.Fprintln(stdout) | |
136 | p("Available Versions :", "") | |
137 | for _, apk := range app.Apks { | |
138 | fmt.Fprintln(stdout) | |
139 | p(" Version :", "%s (%d)", apk.VersName, apk.VersCode) | |
140 | p(" Size :", "%d", apk.Size) | |
141 | p(" MinSdk :", "%d", apk.MinSdk) | |
142 | if apk.MaxSdk > 0 { | |
143 | p(" MaxSdk :", "%d", apk.MaxSdk) | |
144 | } | |
145 | if apk.ABIs != nil { | |
146 | p(" ABIs :", "%s", strings.Join(apk.ABIs, ", ")) | |
147 | } | |
148 | if apk.Perms != nil { | |
149 | p(" Perms :", "%s", strings.Join(apk.Perms, ", ")) | |
150 | } | |
151 | } | |
152 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "errors" | |
7 | "fmt" | |
8 | ) | |
9 | ||
10 | var cmdUninstall = &Command{ | |
11 | UsageLine: "uninstall <appid...>", | |
12 | Short: "Uninstall an app", | |
13 | } | |
14 | ||
15 | func init() { | |
16 | cmdUninstall.Run = runUninstall | |
17 | } | |
18 | ||
19 | func runUninstall(args []string) error { | |
20 | if len(args) < 1 { | |
21 | return fmt.Errorf("no package names given") | |
22 | } | |
23 | device, err := oneDevice() | |
24 | if err != nil { | |
25 | return err | |
26 | } | |
27 | inst, err := device.Installed() | |
28 | if err != nil { | |
29 | return err | |
30 | } | |
31 | for _, id := range args { | |
32 | var err error | |
33 | fmt.Fprintf(stdout, "Uninstalling %s\n", id) | |
34 | if _, installed := inst[id]; installed { | |
35 | err = device.Uninstall(id) | |
36 | } else { | |
37 | err = errors.New("not installed") | |
38 | } | |
39 | if err != nil { | |
40 | return fmt.Errorf("could not uninstall %s: %v", id, err) | |
41 | } | |
42 | } | |
43 | return nil | |
44 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "bytes" | |
7 | "crypto/sha256" | |
8 | "encoding/gob" | |
9 | "fmt" | |
10 | "io" | |
11 | "io/ioutil" | |
12 | "net/http" | |
13 | "os" | |
14 | "path/filepath" | |
15 | "sort" | |
16 | ||
17 | "mvdan.cc/fdroidcl" | |
18 | ) | |
19 | ||
20 | var cmdUpdate = &Command{ | |
21 | UsageLine: "update", | |
22 | Short: "Update the index", | |
23 | } | |
24 | ||
25 | func init() { | |
26 | cmdUpdate.Run = runUpdate | |
27 | } | |
28 | ||
29 | func runUpdate(args []string) error { | |
30 | anyModified := false | |
31 | for _, r := range config.Repos { | |
32 | if !r.Enabled { | |
33 | continue | |
34 | } | |
35 | if err := r.updateIndex(); err == errNotModified { | |
36 | } else if err != nil { | |
37 | return fmt.Errorf("could not update index: %v", err) | |
38 | } else { | |
39 | anyModified = true | |
40 | } | |
41 | } | |
42 | if anyModified { | |
43 | cachePath := filepath.Join(mustCache(), "cache-gob") | |
44 | os.Remove(cachePath) | |
45 | } | |
46 | return nil | |
47 | } | |
48 | ||
49 | const jarFile = "index-v1.jar" | |
50 | ||
51 | func (r *repo) updateIndex() error { | |
52 | url := fmt.Sprintf("%s/%s", r.URL, jarFile) | |
53 | return downloadEtag(url, indexPath(r.ID), nil) | |
54 | } | |
55 | ||
56 | func (r *repo) loadIndex() (*fdroidcl.Index, error) { | |
57 | p := indexPath(r.ID) | |
58 | f, err := os.Open(p) | |
59 | if err != nil { | |
60 | return nil, fmt.Errorf("could not open index: %v", err) | |
61 | } | |
62 | stat, err := f.Stat() | |
63 | if err != nil { | |
64 | return nil, fmt.Errorf("could not stat index: %v", err) | |
65 | } | |
66 | return fdroidcl.LoadIndexJar(f, stat.Size(), nil) | |
67 | } | |
68 | ||
69 | func respEtag(resp *http.Response) string { | |
70 | etags, e := resp.Header["Etag"] | |
71 | if !e || len(etags) == 0 { | |
72 | return "" | |
73 | } | |
74 | return etags[0] | |
75 | } | |
76 | ||
77 | var errNotModified = fmt.Errorf("not modified") | |
78 | ||
79 | func downloadEtag(url, path string, sum []byte) error { | |
80 | fmt.Fprintf(stdout, "Downloading %s... ", url) | |
81 | defer fmt.Fprintln(stdout) | |
82 | req, err := http.NewRequest("GET", url, nil) | |
83 | if err != nil { | |
84 | return err | |
85 | } | |
86 | ||
87 | etagPath := path + "-etag" | |
88 | if _, err := os.Stat(path); err == nil { | |
89 | etag, _ := ioutil.ReadFile(etagPath) | |
90 | req.Header.Add("If-None-Match", string(etag)) | |
91 | } | |
92 | ||
93 | client := &http.Client{} | |
94 | resp, err := client.Do(req) | |
95 | if err != nil { | |
96 | return err | |
97 | } | |
98 | defer resp.Body.Close() | |
99 | if resp.StatusCode >= 400 { | |
100 | return fmt.Errorf("download failed: %d %s", | |
101 | resp.StatusCode, http.StatusText(resp.StatusCode)) | |
102 | } | |
103 | if resp.StatusCode == http.StatusNotModified { | |
104 | fmt.Fprintf(stdout, "not modified") | |
105 | return errNotModified | |
106 | } | |
107 | f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) | |
108 | if err != nil { | |
109 | return err | |
110 | } | |
111 | defer f.Close() | |
112 | if sum == nil { | |
113 | _, err := io.Copy(f, resp.Body) | |
114 | if err != nil { | |
115 | return err | |
116 | } | |
117 | } else { | |
118 | data, err := ioutil.ReadAll(resp.Body) | |
119 | if err != nil { | |
120 | return err | |
121 | } | |
122 | got := sha256.Sum256(data) | |
123 | if !bytes.Equal(sum, got[:]) { | |
124 | return fmt.Errorf("sha256 mismatch") | |
125 | } | |
126 | if _, err := f.Write(data); err != nil { | |
127 | return err | |
128 | } | |
129 | } | |
130 | if err := ioutil.WriteFile(etagPath, []byte(respEtag(resp)), 0644); err != nil { | |
131 | return err | |
132 | } | |
133 | fmt.Fprintf(stdout, "done") | |
134 | return nil | |
135 | } | |
136 | ||
137 | func indexPath(name string) string { | |
138 | return filepath.Join(mustData(), name+".jar") | |
139 | } | |
140 | ||
141 | const cacheVersion = 2 | |
142 | ||
143 | type cache struct { | |
144 | Version int | |
145 | Apps []fdroidcl.App | |
146 | } | |
147 | ||
148 | type apkPtrList []*fdroidcl.Apk | |
149 | ||
150 | func (al apkPtrList) Len() int { return len(al) } | |
151 | func (al apkPtrList) Swap(i, j int) { al[i], al[j] = al[j], al[i] } | |
152 | func (al apkPtrList) Less(i, j int) bool { return al[i].VersCode > al[j].VersCode } | |
153 | ||
154 | func loadIndexes() ([]fdroidcl.App, error) { | |
155 | cachePath := filepath.Join(mustCache(), "cache-gob") | |
156 | if f, err := os.Open(cachePath); err == nil { | |
157 | defer f.Close() | |
158 | var c cache | |
159 | if err := gob.NewDecoder(f).Decode(&c); err == nil && c.Version == cacheVersion { | |
160 | return c.Apps, nil | |
161 | } | |
162 | } | |
163 | m := make(map[string]*fdroidcl.App) | |
164 | for _, r := range config.Repos { | |
165 | if !r.Enabled { | |
166 | continue | |
167 | } | |
168 | index, err := r.loadIndex() | |
169 | if err != nil { | |
170 | return nil, fmt.Errorf("error while loading %s: %v", r.ID, err) | |
171 | } | |
172 | for i := range index.Apps { | |
173 | app := index.Apps[i] | |
174 | orig, e := m[app.PackageName] | |
175 | if !e { | |
176 | m[app.PackageName] = &app | |
177 | continue | |
178 | } | |
179 | apks := append(orig.Apks, app.Apks...) | |
180 | // We use a stable sort so that repository order | |
181 | // (priority) is preserved amongst apks with the same | |
182 | // vercode on apps | |
183 | sort.Stable(apkPtrList(apks)) | |
184 | m[app.PackageName].Apks = apks | |
185 | } | |
186 | } | |
187 | apps := make([]fdroidcl.App, 0, len(m)) | |
188 | for _, a := range m { | |
189 | apps = append(apps, *a) | |
190 | } | |
191 | sort.Sort(fdroidcl.AppList(apps)) | |
192 | if f, err := os.Create(cachePath); err == nil { | |
193 | defer f.Close() | |
194 | gob.NewEncoder(f).Encode(cache{ | |
195 | Version: cacheVersion, | |
196 | Apps: apps, | |
197 | }) | |
198 | } | |
199 | return apps, nil | |
200 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "encoding/json" | |
7 | "fmt" | |
8 | "os" | |
9 | ) | |
10 | ||
11 | var cmdDefaults = &Command{ | |
12 | UsageLine: "defaults", | |
13 | Short: "Reset to the default settings", | |
14 | } | |
15 | ||
16 | func init() { | |
17 | cmdDefaults.Run = runDefaults | |
18 | } | |
19 | ||
20 | func runDefaults(args []string) error { | |
21 | if len(args) > 0 { | |
22 | return fmt.Errorf("no arguments allowed") | |
23 | } | |
24 | return writeConfig(&config) | |
25 | } | |
26 | ||
27 | func writeConfig(c *userConfig) error { | |
28 | b, err := json.MarshalIndent(c, "", "\t") | |
29 | if err != nil { | |
30 | return fmt.Errorf("cannot encode config: %v", err) | |
31 | } | |
32 | f, err := os.Create(configPath()) | |
33 | if err != nil { | |
34 | return fmt.Errorf("cannot create config file: %v", err) | |
35 | } | |
36 | _, err = f.Write(b) | |
37 | if cerr := f.Close(); err == nil { | |
38 | err = cerr | |
39 | } | |
40 | return err | |
41 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | ||
8 | "mvdan.cc/fdroidcl/adb" | |
9 | ) | |
10 | ||
11 | var cmdDevices = &Command{ | |
12 | UsageLine: "devices", | |
13 | Short: "List connected devices", | |
14 | } | |
15 | ||
16 | func init() { | |
17 | cmdDevices.Run = runDevices | |
18 | } | |
19 | ||
20 | func runDevices(args []string) error { | |
21 | if err := startAdbIfNeeded(); err != nil { | |
22 | return err | |
23 | } | |
24 | devices, err := adb.Devices() | |
25 | if err != nil { | |
26 | return fmt.Errorf("could not get devices: %v", err) | |
27 | } | |
28 | for _, device := range devices { | |
29 | fmt.Printf("%s - %s (%s)\n", device.ID, device.Model, device.Product) | |
30 | } | |
31 | return nil | |
32 | } | |
33 | ||
34 | func startAdbIfNeeded() error { | |
35 | if adb.IsServerRunning() { | |
36 | return nil | |
37 | } | |
38 | if err := adb.StartServer(); err != nil { | |
39 | return fmt.Errorf("could not start ADB server: %v", err) | |
40 | } | |
41 | return nil | |
42 | } | |
43 | ||
44 | func maybeOneDevice() (*adb.Device, error) { | |
45 | if err := startAdbIfNeeded(); err != nil { | |
46 | return nil, err | |
47 | } | |
48 | devices, err := adb.Devices() | |
49 | if err != nil { | |
50 | return nil, fmt.Errorf("could not get devices: %v", err) | |
51 | } | |
52 | if len(devices) > 1 { | |
53 | return nil, fmt.Errorf("at most one connected device can be used") | |
54 | } | |
55 | if len(devices) < 1 { | |
56 | return nil, nil | |
57 | } | |
58 | return devices[0], nil | |
59 | } | |
60 | ||
61 | func oneDevice() (*adb.Device, error) { | |
62 | device, err := maybeOneDevice() | |
63 | if err == nil && device == nil { | |
64 | err = fmt.Errorf("a connected device is needed") | |
65 | } | |
66 | return device, err | |
67 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | "path/filepath" | |
8 | ||
9 | "mvdan.cc/fdroidcl/fdroid" | |
10 | ) | |
11 | ||
12 | var cmdDownload = &Command{ | |
13 | UsageLine: "download <appid...>", | |
14 | Short: "Download an app", | |
15 | } | |
16 | ||
17 | func init() { | |
18 | cmdDownload.Run = runDownload | |
19 | } | |
20 | ||
21 | func runDownload(args []string) error { | |
22 | if len(args) < 1 { | |
23 | return fmt.Errorf("no package names given") | |
24 | } | |
25 | apps, err := findApps(args) | |
26 | if err != nil { | |
27 | return err | |
28 | } | |
29 | // don't fail a download if adb is not installed | |
30 | device, _ := maybeOneDevice() | |
31 | for _, app := range apps { | |
32 | apk := app.SuggestedApk(device) | |
33 | if apk == nil { | |
34 | return fmt.Errorf("no suggested APK found for %s", app.PackageName) | |
35 | } | |
36 | path, err := downloadApk(apk) | |
37 | if err != nil { | |
38 | return err | |
39 | } | |
40 | fmt.Printf("APK available in %s\n", path) | |
41 | } | |
42 | return nil | |
43 | } | |
44 | ||
45 | func downloadApk(apk *fdroid.Apk) (string, error) { | |
46 | url := apk.URL() | |
47 | path := apkPath(apk.ApkName) | |
48 | if err := downloadEtag(url, path, apk.Hash); err == errNotModified { | |
49 | } else if err != nil { | |
50 | return "", fmt.Errorf("could not download %s: %v", apk.AppID, err) | |
51 | } | |
52 | return path, nil | |
53 | } | |
54 | ||
55 | func apkPath(apkname string) string { | |
56 | apksDir := subdir(mustCache(), "apks") | |
57 | return filepath.Join(apksDir, apkname) | |
58 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package fdroid | |
4 | ||
5 | import ( | |
6 | "encoding/hex" | |
7 | "strconv" | |
8 | "time" | |
9 | ) | |
10 | ||
11 | type HexVal []byte | |
12 | ||
13 | func (hv *HexVal) String() string { | |
14 | return hex.EncodeToString(*hv) | |
15 | } | |
16 | ||
17 | func (hv *HexVal) UnmarshalText(text []byte) error { | |
18 | b, err := hex.DecodeString(string(text)) | |
19 | if err != nil { | |
20 | return err | |
21 | } | |
22 | *hv = b | |
23 | return nil | |
24 | } | |
25 | ||
26 | // UnixDate is F-Droid's timestamp format. It's a unix time, but in | |
27 | // milliseconds. We can ignore the extra digits, as they're always zero, and | |
28 | // won't be displayed anyway. | |
29 | type UnixDate struct { | |
30 | time.Time | |
31 | } | |
32 | ||
33 | func (ud *UnixDate) String() string { | |
34 | return ud.Format("2006-01-02") | |
35 | } | |
36 | ||
37 | func (ud *UnixDate) UnmarshalJSON(data []byte) error { | |
38 | msec, err := strconv.ParseInt(string(data), 10, 64) | |
39 | if err != nil { | |
40 | return err | |
41 | } | |
42 | t := time.Unix(msec/1000, 0).UTC() | |
43 | *ud = UnixDate{t} | |
44 | return nil | |
45 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package fdroid | |
4 | ||
5 | import ( | |
6 | "encoding/json" | |
7 | "encoding/xml" | |
8 | "fmt" | |
9 | "html" | |
10 | "io" | |
11 | "sort" | |
12 | "strings" | |
13 | ||
14 | "mvdan.cc/fdroidcl/adb" | |
15 | ) | |
16 | ||
17 | type Index struct { | |
18 | Repo Repo `json:"repo"` | |
19 | Apps []App `json:"apps"` | |
20 | Packages map[string][]Apk `json:"packages"` | |
21 | } | |
22 | ||
23 | type Repo struct { | |
24 | Name string `json:"name"` | |
25 | Timestamp UnixDate `json:"timestamp"` | |
26 | Address string `json:"address"` | |
27 | Icon string `json:"icon"` | |
28 | Version int `json:"version"` | |
29 | MaxAge int `json:"maxage"` | |
30 | Description string `json:"description"` | |
31 | } | |
32 | ||
33 | // App is an Android application | |
34 | type App struct { | |
35 | PackageName string `json:"packageName"` | |
36 | Name string `json:"name"` | |
37 | Summary string `json:"summary"` | |
38 | Added UnixDate `json:"added"` | |
39 | Updated UnixDate `json:"lastUpdated"` | |
40 | Icon string `json:"icon"` | |
41 | Description string `json:"description"` | |
42 | License string `json:"license"` | |
43 | Categories []string `json:"categories"` | |
44 | Website string `json:"webSite"` | |
45 | SourceCode string `json:"sourceCode"` | |
46 | IssueTracker string `json:"issueTracker"` | |
47 | Changelog string `json:"changelog"` | |
48 | Donate string `json:"donate"` | |
49 | Bitcoin string `json:"bitcoin"` | |
50 | Litecoin string `json:"litecoin"` | |
51 | FlattrID string `json:"flattr"` | |
52 | SugVersName string `json:"suggestedVersionName"` | |
53 | SugVersCode int `json:"suggestedVersionCode,string"` | |
54 | ||
55 | Localized map[string]Localization `json:"localized"` | |
56 | ||
57 | Apks []*Apk `json:"-"` | |
58 | } | |
59 | ||
60 | type Localization struct { | |
61 | Summary string `json:"summary"` | |
62 | Description string `json:"description"` | |
63 | } | |
64 | ||
65 | type IconDensity uint | |
66 | ||
67 | const ( | |
68 | UnknownDensity IconDensity = 0 | |
69 | LowDensity IconDensity = 120 | |
70 | MediumDensity IconDensity = 160 | |
71 | HighDensity IconDensity = 240 | |
72 | XHighDensity IconDensity = 320 | |
73 | XXHighDensity IconDensity = 480 | |
74 | XXXHighDensity IconDensity = 640 | |
75 | ) | |
76 | ||
77 | func getIconsDir(density IconDensity) string { | |
78 | if density == UnknownDensity { | |
79 | return "icons" | |
80 | } | |
81 | for _, d := range [...]IconDensity{ | |
82 | XXXHighDensity, | |
83 | XXHighDensity, | |
84 | XHighDensity, | |
85 | HighDensity, | |
86 | MediumDensity, | |
87 | } { | |
88 | if density >= d { | |
89 | return fmt.Sprintf("icons-%d", d) | |
90 | } | |
91 | } | |
92 | return fmt.Sprintf("icons-%d", LowDensity) | |
93 | } | |
94 | ||
95 | func (a *App) IconURLForDensity(density IconDensity) string { | |
96 | if len(a.Apks) == 0 { | |
97 | return "" | |
98 | } | |
99 | return fmt.Sprintf("%s/%s/%s", a.Apks[0].RepoURL, | |
100 | getIconsDir(density), a.Icon) | |
101 | } | |
102 | ||
103 | func (a *App) IconURL() string { | |
104 | return a.IconURLForDensity(UnknownDensity) | |
105 | } | |
106 | ||
107 | func (a *App) TextDesc(w io.Writer) { | |
108 | reader := strings.NewReader(a.Description) | |
109 | decoder := xml.NewDecoder(reader) | |
110 | firstParagraph := true | |
111 | linePrefix := "" | |
112 | colsUsed := 0 | |
113 | var links []string | |
114 | linked := false | |
115 | for { | |
116 | token, err := decoder.Token() | |
117 | if err == io.EOF || token == nil { | |
118 | break | |
119 | } | |
120 | switch t := token.(type) { | |
121 | case xml.StartElement: | |
122 | switch t.Name.Local { | |
123 | case "p": | |
124 | if firstParagraph { | |
125 | firstParagraph = false | |
126 | } else { | |
127 | fmt.Fprintln(w) | |
128 | } | |
129 | linePrefix = "" | |
130 | colsUsed = 0 | |
131 | case "li": | |
132 | fmt.Fprint(w, "\n *") | |
133 | linePrefix = " " | |
134 | colsUsed = 0 | |
135 | case "a": | |
136 | for _, attr := range t.Attr { | |
137 | if attr.Name.Local == "href" { | |
138 | links = append(links, attr.Value) | |
139 | linked = true | |
140 | break | |
141 | } | |
142 | } | |
143 | } | |
144 | case xml.EndElement: | |
145 | switch t.Name.Local { | |
146 | case "p", "ul", "ol": | |
147 | fmt.Fprintln(w) | |
148 | } | |
149 | case xml.CharData: | |
150 | left := string(t) | |
151 | if linked { | |
152 | left += fmt.Sprintf("[%d]", len(links)-1) | |
153 | linked = false | |
154 | } | |
155 | limit := 80 - len(linePrefix) - colsUsed | |
156 | firstLine := true | |
157 | for len(left) > limit { | |
158 | last := 0 | |
159 | for i, c := range left { | |
160 | if i >= limit { | |
161 | break | |
162 | } | |
163 | if c == ' ' { | |
164 | last = i | |
165 | } | |
166 | } | |
167 | if firstLine { | |
168 | firstLine = false | |
169 | limit += colsUsed | |
170 | } else { | |
171 | fmt.Fprint(w, linePrefix) | |
172 | } | |
173 | fmt.Fprintln(w, left[:last]) | |
174 | left = left[last+1:] | |
175 | colsUsed = 0 | |
176 | } | |
177 | if !firstLine { | |
178 | fmt.Fprint(w, linePrefix) | |
179 | } | |
180 | fmt.Fprint(w, left) | |
181 | colsUsed += len(left) | |
182 | } | |
183 | } | |
184 | if len(links) > 0 { | |
185 | fmt.Fprintln(w) | |
186 | for i, link := range links { | |
187 | fmt.Fprintf(w, "[%d] %s\n", i, link) | |
188 | } | |
189 | } | |
190 | } | |
191 | ||
192 | // Apk is an Android package | |
193 | type Apk struct { | |
194 | VersName string `json:"versionName"` | |
195 | VersCode int `json:"versionCode"` | |
196 | Size int64 `json:"size"` | |
197 | MinSdk int `json:"sdkver"` | |
198 | MaxSdk int `json:"maxsdkver"` | |
199 | ABIs []string `json:"nativecode"` | |
200 | ApkName string `json:"apkname"` | |
201 | SrcName string `json:"srcname"` | |
202 | Sig HexVal `json:"sig"` | |
203 | Signer HexVal `json:"signer"` | |
204 | Added UnixDate `json:"added"` | |
205 | Perms []string `json:"permissions"` | |
206 | Feats []string `json:"features"` | |
207 | Hash HexVal `json:"hash"` | |
208 | HashType string `json:"hashType"` | |
209 | ||
210 | AppID string `json:"-"` | |
211 | RepoURL string `json:"-"` | |
212 | } | |
213 | ||
214 | func (a *Apk) URL() string { | |
215 | return fmt.Sprintf("%s/%s", a.RepoURL, a.ApkName) | |
216 | } | |
217 | ||
218 | func (a *Apk) SrcURL() string { | |
219 | return fmt.Sprintf("%s/%s", a.RepoURL, a.SrcName) | |
220 | } | |
221 | ||
222 | func (a *Apk) IsCompatibleABI(ABIs []string) bool { | |
223 | if len(a.ABIs) == 0 { | |
224 | return true // APK does not contain native code | |
225 | } | |
226 | for _, apkABI := range a.ABIs { | |
227 | for _, abi := range ABIs { | |
228 | if apkABI == abi { | |
229 | return true | |
230 | } | |
231 | } | |
232 | } | |
233 | return false | |
234 | } | |
235 | ||
236 | func (a *Apk) IsCompatibleAPILevel(sdk int) bool { | |
237 | return sdk >= a.MinSdk && (a.MaxSdk == 0 || sdk <= a.MaxSdk) | |
238 | } | |
239 | ||
240 | func (a *Apk) IsCompatible(device *adb.Device) bool { | |
241 | if device == nil { | |
242 | return true | |
243 | } | |
244 | return a.IsCompatibleABI(device.ABIs) && | |
245 | a.IsCompatibleAPILevel(device.APILevel) | |
246 | } | |
247 | ||
248 | type AppList []App | |
249 | ||
250 | func (al AppList) Len() int { return len(al) } | |
251 | func (al AppList) Swap(i, j int) { al[i], al[j] = al[j], al[i] } | |
252 | func (al AppList) Less(i, j int) bool { return al[i].PackageName < al[j].PackageName } | |
253 | ||
254 | type ApkList []Apk | |
255 | ||
256 | func (al ApkList) Len() int { return len(al) } | |
257 | func (al ApkList) Swap(i, j int) { al[i], al[j] = al[j], al[i] } | |
258 | func (al ApkList) Less(i, j int) bool { return al[i].VersCode > al[j].VersCode } | |
259 | ||
260 | func LoadIndexJSON(r io.Reader) (*Index, error) { | |
261 | var index Index | |
262 | decoder := json.NewDecoder(r) | |
263 | if err := decoder.Decode(&index); err != nil { | |
264 | return nil, err | |
265 | } | |
266 | ||
267 | sort.Sort(AppList(index.Apps)) | |
268 | ||
269 | for i := range index.Apps { | |
270 | app := &index.Apps[i] | |
271 | english, enOK := app.Localized["en"] | |
272 | if !enOK { | |
273 | english, enOK = app.Localized["en-US"] | |
274 | } | |
275 | ||
276 | // TODO: why does the json index contain html escapes? | |
277 | app.Name = html.UnescapeString(app.Name) | |
278 | ||
279 | if app.Summary == "" && enOK { | |
280 | app.Summary = english.Summary | |
281 | } | |
282 | if app.Description == "" && enOK { | |
283 | app.Description = english.Description | |
284 | } | |
285 | app.Summary = strings.TrimSpace(app.Summary) | |
286 | sort.Sort(ApkList(index.Packages[app.PackageName])) | |
287 | for i := range index.Packages[app.PackageName] { | |
288 | apk := &index.Packages[app.PackageName][i] | |
289 | apk.AppID = app.PackageName | |
290 | apk.RepoURL = index.Repo.Address | |
291 | ||
292 | // TODO: why does the json index contain html escapes? | |
293 | apk.VersName = html.UnescapeString(apk.VersName) | |
294 | ||
295 | app.Apks = append(app.Apks, apk) | |
296 | } | |
297 | } | |
298 | return &index, nil | |
299 | } | |
300 | ||
301 | func (a *App) SuggestedApk(device *adb.Device) *Apk { | |
302 | for _, apk := range a.Apks { | |
303 | if a.SugVersCode >= apk.VersCode && apk.IsCompatible(device) { | |
304 | return apk | |
305 | } | |
306 | } | |
307 | // fall back to the first compatible apk | |
308 | for _, apk := range a.Apks { | |
309 | if apk.IsCompatible(device) { | |
310 | return apk | |
311 | } | |
312 | } | |
313 | return nil | |
314 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package fdroid | |
4 | ||
5 | import ( | |
6 | "bytes" | |
7 | "reflect" | |
8 | "strings" | |
9 | "testing" | |
10 | "time" | |
11 | ||
12 | "github.com/kr/pretty" | |
13 | ) | |
14 | ||
15 | func TestTextDesc(t *testing.T) { | |
16 | for _, c := range []struct { | |
17 | in string | |
18 | want string | |
19 | }{ | |
20 | { | |
21 | "Simple description.", | |
22 | "Simple description.", | |
23 | }, | |
24 | { | |
25 | "<p>Simple description.</p>", | |
26 | "Simple description.\n", | |
27 | }, | |
28 | { | |
29 | "<p>Multiple</p><p>Paragraphs</p>", | |
30 | "Multiple\n\nParagraphs\n", | |
31 | }, | |
32 | { | |
33 | "<p>Single, very long paragraph that is over 80 characters long and doesn't fit in a single line.</p>", | |
34 | "Single, very long paragraph that is over 80 characters long and doesn't fit in\na single line.\n", | |
35 | }, | |
36 | { | |
37 | "<p>Unordered list:</p><ul><li> Item</li><li> Another item</li></ul>", | |
38 | "Unordered list:\n\n * Item\n * Another item\n", | |
39 | }, | |
40 | { | |
41 | "<p>Link: <a href=\"http://foo.bar\">link title</a> text</p>", | |
42 | "Link: link title[0] text\n\n[0] http://foo.bar\n", | |
43 | }, | |
44 | { | |
45 | "<p>Links: <a href=\"foo\">foo1</a> <a href=\"bar\">bar1</a></p>", | |
46 | "Links: foo1[0] bar1[1]\n\n[0] foo\n[1] bar\n", | |
47 | }, | |
48 | } { | |
49 | app := App{Description: c.in} | |
50 | var b bytes.Buffer | |
51 | app.TextDesc(&b) | |
52 | got := b.String() | |
53 | if got != c.want { | |
54 | t.Fatalf("Unexpected description.\nGot:\n%s\nWant:\n%s\n", | |
55 | got, c.want) | |
56 | } | |
57 | } | |
58 | } | |
59 | ||
60 | func TestLoadIndexJSON(t *testing.T) { | |
61 | in := ` | |
62 | { | |
63 | "repo": { | |
64 | "name": "Foo", | |
65 | "version": 19, | |
66 | "timestamp": 1528184950000 | |
67 | }, | |
68 | "requests": { | |
69 | "install": [], | |
70 | "uninstall": [] | |
71 | }, | |
72 | "apps": [ | |
73 | { | |
74 | "packageName": "foo.bar", | |
75 | "name": "Foo bar", | |
76 | "categories": ["Cat1", "Cat2"], | |
77 | "added": 1443734950000, | |
78 | "suggestedVersionName": "1.0", | |
79 | "suggestedVersionCode": "1" | |
80 | }, | |
81 | { | |
82 | "packageName": "localized.app", | |
83 | "localized": { | |
84 | "en": { | |
85 | "summary": "summary in english\n" | |
86 | } | |
87 | } | |
88 | } | |
89 | ], | |
90 | "packages": { | |
91 | "foo.bar": [ | |
92 | { | |
93 | "versionName": "1.0", | |
94 | "versionCode": 1, | |
95 | "hash": "1e4c77d8c9fa03b3a9c42360dc55468f378bbacadeaf694daea304fe1a2750f4", | |
96 | "hashType": "sha256", | |
97 | "sig": "c0f3a6d46025bf41613c5e81781e517a", | |
98 | "signer": "573c2762a2ff87c4c1ef104b35147c8c316676e5d072ec636fc718f35df6cf22" | |
99 | } | |
100 | ] | |
101 | } | |
102 | } | |
103 | ` | |
104 | want := Index{ | |
105 | Repo: Repo{ | |
106 | Name: "Foo", | |
107 | Version: 19, | |
108 | Timestamp: UnixDate{time.Unix(1528184950, 0).UTC()}, | |
109 | }, | |
110 | Apps: []App{ | |
111 | { | |
112 | PackageName: "foo.bar", | |
113 | Name: "Foo bar", | |
114 | Categories: []string{"Cat1", "Cat2"}, | |
115 | Added: UnixDate{time.Unix(1443734950, 0).UTC()}, | |
116 | SugVersName: "1.0", | |
117 | SugVersCode: 1, | |
118 | Apks: []*Apk{nil}, | |
119 | }, | |
120 | { | |
121 | PackageName: "localized.app", | |
122 | Summary: "summary in english", | |
123 | Localized: map[string]Localization{ | |
124 | "en": {Summary: "summary in english\n"}, | |
125 | }, | |
126 | }, | |
127 | }, | |
128 | Packages: map[string][]Apk{"foo.bar": { | |
129 | { | |
130 | VersName: "1.0", | |
131 | VersCode: 1, | |
132 | Sig: HexVal{0xc0, 0xf3, 0xa6, 0xd4, 0x60, 0x25, 0xbf, 0x41, 0x61, 0x3c, 0x5e, 0x81, 0x78, 0x1e, 0x51, 0x7a}, | |
133 | Signer: HexVal{0x57, 0x3c, 0x27, 0x62, 0xa2, 0xff, 0x87, 0xc4, 0xc1, 0xef, 0x10, 0x4b, 0x35, 0x14, 0x7c, 0x8c, 0x31, 0x66, 0x76, 0xe5, 0xd0, 0x72, 0xec, 0x63, 0x6f, 0xc7, 0x18, 0xf3, 0x5d, 0xf6, 0xcf, 0x22}, | |
134 | Hash: HexVal{0x1e, 0x4c, 0x77, 0xd8, 0xc9, 0xfa, 0x3, 0xb3, 0xa9, 0xc4, 0x23, 0x60, 0xdc, 0x55, 0x46, 0x8f, 0x37, 0x8b, 0xba, 0xca, 0xde, 0xaf, 0x69, 0x4d, 0xae, 0xa3, 0x4, 0xfe, 0x1a, 0x27, 0x50, 0xf4}, | |
135 | HashType: "sha256", | |
136 | }, | |
137 | }}, | |
138 | } | |
139 | want.Apps[0].Apks[0] = &want.Packages["foo.bar"][0] | |
140 | r := strings.NewReader(in) | |
141 | index, err := LoadIndexJSON(r) | |
142 | if err != nil { | |
143 | t.Fatalf("Unexpected error: %v", err) | |
144 | } | |
145 | got := *index | |
146 | for i := range want.Apps { | |
147 | app := &want.Apps[i] | |
148 | for _, apk := range app.Apks { | |
149 | apk.AppID = app.PackageName | |
150 | } | |
151 | } | |
152 | if !reflect.DeepEqual(got, want) { | |
153 | t.Fatalf("Unexpected index.\n%s", | |
154 | strings.Join(pretty.Diff(want, got), "\n")) | |
155 | } | |
156 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package fdroid | |
4 | ||
5 | import ( | |
6 | "archive/zip" | |
7 | "errors" | |
8 | "io" | |
9 | ) | |
10 | ||
11 | const indexPath = "index-v1.json" | |
12 | ||
13 | var ErrNoIndex = errors.New("no json index found inside jar") | |
14 | ||
15 | func LoadIndexJar(r io.ReaderAt, size int64, pubkey []byte) (*Index, error) { | |
16 | reader, err := zip.NewReader(r, size) | |
17 | if err != nil { | |
18 | return nil, err | |
19 | } | |
20 | var index io.ReadCloser | |
21 | for _, f := range reader.File { | |
22 | if f.Name == indexPath { | |
23 | index, err = f.Open() | |
24 | if err != nil { | |
25 | return nil, err | |
26 | } | |
27 | break | |
28 | } | |
29 | } | |
30 | if index == nil { | |
31 | return nil, ErrNoIndex | |
32 | } | |
33 | defer index.Close() | |
34 | return LoadIndexJSON(index) | |
35 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package fdroidcl | |
4 | ||
5 | import ( | |
6 | "encoding/hex" | |
7 | "strconv" | |
8 | "time" | |
9 | ) | |
10 | ||
11 | type HexVal []byte | |
12 | ||
13 | func (hv *HexVal) String() string { | |
14 | return hex.EncodeToString(*hv) | |
15 | } | |
16 | ||
17 | func (hv *HexVal) UnmarshalText(text []byte) error { | |
18 | b, err := hex.DecodeString(string(text)) | |
19 | if err != nil { | |
20 | return err | |
21 | } | |
22 | *hv = b | |
23 | return nil | |
24 | } | |
25 | ||
26 | // UnixDate is F-Droid's timestamp format. It's a unix time, but in | |
27 | // milliseconds. We can ignore the extra digits, as they're always zero, and | |
28 | // won't be displayed anyway. | |
29 | type UnixDate struct { | |
30 | time.Time | |
31 | } | |
32 | ||
33 | func (ud *UnixDate) String() string { | |
34 | return ud.Format("2006-01-02") | |
35 | } | |
36 | ||
37 | func (ud *UnixDate) UnmarshalJSON(data []byte) error { | |
38 | msec, err := strconv.ParseInt(string(data), 10, 64) | |
39 | if err != nil { | |
40 | return err | |
41 | } | |
42 | t := time.Unix(msec/1000, 0).UTC() | |
43 | *ud = UnixDate{t} | |
44 | return nil | |
45 | } |
0 | module mvdan.cc/fdroidcl | |
1 | ||
2 | require ( | |
3 | github.com/kr/pretty v0.1.0 | |
4 | github.com/rogpeppe/go-internal v1.1.0 | |
5 | ) |
0 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= | |
1 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | |
2 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | |
3 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= | |
4 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | |
5 | github.com/rogpeppe/go-internal v1.1.0 h1:g0fH8RicVgNl+zVZDCDfbdWxAWoAEJyI7I3TZYXFiig= | |
6 | github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | |
7 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
8 | gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= | |
9 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package fdroidcl | |
4 | ||
5 | import ( | |
6 | "encoding/json" | |
7 | "encoding/xml" | |
8 | "fmt" | |
9 | "io" | |
10 | "sort" | |
11 | "strings" | |
12 | ||
13 | "mvdan.cc/fdroidcl/adb" | |
14 | ) | |
15 | ||
16 | type Index struct { | |
17 | Repo Repo `json:"repo"` | |
18 | Apps []App `json:"apps"` | |
19 | Packages map[string][]Apk `json:"packages"` | |
20 | } | |
21 | ||
22 | type Repo struct { | |
23 | Name string `json:"name"` | |
24 | Timestamp UnixDate `json:"timestamp"` | |
25 | Address string `json:"address"` | |
26 | Icon string `json:"icon"` | |
27 | Version int `json:"version"` | |
28 | MaxAge int `json:"maxage"` | |
29 | Description string `json:"description"` | |
30 | } | |
31 | ||
32 | // App is an Android application | |
33 | type App struct { | |
34 | PackageName string `json:"packageName"` | |
35 | Name string `json:"name"` | |
36 | Summary string `json:"summary"` | |
37 | Added UnixDate `json:"added"` | |
38 | Updated UnixDate `json:"lastUpdated"` | |
39 | Icon string `json:"icon"` | |
40 | Description string `json:"description"` | |
41 | License string `json:"license"` | |
42 | Categories []string `json:"categories"` | |
43 | Website string `json:"webSite"` | |
44 | SourceCode string `json:"sourceCode"` | |
45 | IssueTracker string `json:"issueTracker"` | |
46 | Changelog string `json:"changelog"` | |
47 | Donate string `json:"donate"` | |
48 | Bitcoin string `json:"bitcoin"` | |
49 | Litecoin string `json:"litecoin"` | |
50 | FlattrID string `json:"flattr"` | |
51 | SugVersName string `json:"suggestedVersionName"` | |
52 | SugVersCode int `json:"suggestedVersionCode,string"` | |
53 | ||
54 | Localized map[string]Localization `json:"localized"` | |
55 | ||
56 | Apks []*Apk `json:"-"` | |
57 | } | |
58 | ||
59 | type Localization struct { | |
60 | Summary string `json:"summary"` | |
61 | Description string `json:"description"` | |
62 | } | |
63 | ||
64 | type IconDensity uint | |
65 | ||
66 | const ( | |
67 | UnknownDensity IconDensity = 0 | |
68 | LowDensity IconDensity = 120 | |
69 | MediumDensity IconDensity = 160 | |
70 | HighDensity IconDensity = 240 | |
71 | XHighDensity IconDensity = 320 | |
72 | XXHighDensity IconDensity = 480 | |
73 | XXXHighDensity IconDensity = 640 | |
74 | ) | |
75 | ||
76 | func getIconsDir(density IconDensity) string { | |
77 | if density == UnknownDensity { | |
78 | return "icons" | |
79 | } | |
80 | for _, d := range [...]IconDensity{ | |
81 | XXXHighDensity, | |
82 | XXHighDensity, | |
83 | XHighDensity, | |
84 | HighDensity, | |
85 | MediumDensity, | |
86 | } { | |
87 | if density >= d { | |
88 | return fmt.Sprintf("icons-%d", d) | |
89 | } | |
90 | } | |
91 | return fmt.Sprintf("icons-%d", LowDensity) | |
92 | } | |
93 | ||
94 | func (a *App) IconURLForDensity(density IconDensity) string { | |
95 | if len(a.Apks) == 0 { | |
96 | return "" | |
97 | } | |
98 | return fmt.Sprintf("%s/%s/%s", a.Apks[0].RepoURL, | |
99 | getIconsDir(density), a.Icon) | |
100 | } | |
101 | ||
102 | func (a *App) IconURL() string { | |
103 | return a.IconURLForDensity(UnknownDensity) | |
104 | } | |
105 | ||
106 | func (a *App) TextDesc(w io.Writer) { | |
107 | reader := strings.NewReader(a.Description) | |
108 | decoder := xml.NewDecoder(reader) | |
109 | firstParagraph := true | |
110 | linePrefix := "" | |
111 | colsUsed := 0 | |
112 | var links []string | |
113 | linked := false | |
114 | for { | |
115 | token, err := decoder.Token() | |
116 | if err == io.EOF || token == nil { | |
117 | break | |
118 | } | |
119 | switch t := token.(type) { | |
120 | case xml.StartElement: | |
121 | switch t.Name.Local { | |
122 | case "p": | |
123 | if firstParagraph { | |
124 | firstParagraph = false | |
125 | } else { | |
126 | fmt.Fprintln(w) | |
127 | } | |
128 | linePrefix = "" | |
129 | colsUsed = 0 | |
130 | case "li": | |
131 | fmt.Fprint(w, "\n *") | |
132 | linePrefix = " " | |
133 | colsUsed = 0 | |
134 | case "a": | |
135 | for _, attr := range t.Attr { | |
136 | if attr.Name.Local == "href" { | |
137 | links = append(links, attr.Value) | |
138 | linked = true | |
139 | break | |
140 | } | |
141 | } | |
142 | } | |
143 | case xml.EndElement: | |
144 | switch t.Name.Local { | |
145 | case "p", "ul", "ol": | |
146 | fmt.Fprintln(w) | |
147 | } | |
148 | case xml.CharData: | |
149 | left := string(t) | |
150 | if linked { | |
151 | left += fmt.Sprintf("[%d]", len(links)-1) | |
152 | linked = false | |
153 | } | |
154 | limit := 80 - len(linePrefix) - colsUsed | |
155 | firstLine := true | |
156 | for len(left) > limit { | |
157 | last := 0 | |
158 | for i, c := range left { | |
159 | if i >= limit { | |
160 | break | |
161 | } | |
162 | if c == ' ' { | |
163 | last = i | |
164 | } | |
165 | } | |
166 | if firstLine { | |
167 | firstLine = false | |
168 | limit += colsUsed | |
169 | } else { | |
170 | fmt.Fprint(w, linePrefix) | |
171 | } | |
172 | fmt.Fprintln(w, left[:last]) | |
173 | left = left[last+1:] | |
174 | colsUsed = 0 | |
175 | } | |
176 | if !firstLine { | |
177 | fmt.Fprint(w, linePrefix) | |
178 | } | |
179 | fmt.Fprint(w, left) | |
180 | colsUsed += len(left) | |
181 | } | |
182 | } | |
183 | if len(links) > 0 { | |
184 | fmt.Fprintln(w) | |
185 | for i, link := range links { | |
186 | fmt.Fprintf(w, "[%d] %s\n", i, link) | |
187 | } | |
188 | } | |
189 | } | |
190 | ||
191 | // Apk is an Android package | |
192 | type Apk struct { | |
193 | VersName string `json:"versionName"` | |
194 | VersCode int `json:"versionCode"` | |
195 | Size int64 `json:"size"` | |
196 | MinSdk int `json:"sdkver"` | |
197 | MaxSdk int `json:"maxsdkver"` | |
198 | ABIs []string `json:"nativecode"` | |
199 | ApkName string `json:"apkname"` | |
200 | SrcName string `json:"srcname"` | |
201 | Sig HexVal `json:"sig"` | |
202 | Signer HexVal `json:"signer"` | |
203 | Added UnixDate `json:"added"` | |
204 | Perms []string `json:"permissions"` | |
205 | Feats []string `json:"features"` | |
206 | Hash HexVal `json:"hash"` | |
207 | HashType string `json:"hashType"` | |
208 | ||
209 | AppID string `json:"-"` | |
210 | RepoURL string `json:"-"` | |
211 | } | |
212 | ||
213 | func (a *Apk) URL() string { | |
214 | return fmt.Sprintf("%s/%s", a.RepoURL, a.ApkName) | |
215 | } | |
216 | ||
217 | func (a *Apk) SrcURL() string { | |
218 | return fmt.Sprintf("%s/%s", a.RepoURL, a.SrcName) | |
219 | } | |
220 | ||
221 | func (a *Apk) IsCompatibleABI(ABIs []string) bool { | |
222 | if len(a.ABIs) == 0 { | |
223 | return true // APK does not contain native code | |
224 | } | |
225 | for _, apkABI := range a.ABIs { | |
226 | for _, abi := range ABIs { | |
227 | if apkABI == abi { | |
228 | return true | |
229 | } | |
230 | } | |
231 | } | |
232 | return false | |
233 | } | |
234 | ||
235 | func (a *Apk) IsCompatibleAPILevel(sdk int) bool { | |
236 | return sdk >= a.MinSdk && (a.MaxSdk == 0 || sdk <= a.MaxSdk) | |
237 | } | |
238 | ||
239 | func (a *Apk) IsCompatible(device *adb.Device) bool { | |
240 | if device == nil { | |
241 | return true | |
242 | } | |
243 | return a.IsCompatibleABI(device.ABIs) && | |
244 | a.IsCompatibleAPILevel(device.APILevel) | |
245 | } | |
246 | ||
247 | type AppList []App | |
248 | ||
249 | func (al AppList) Len() int { return len(al) } | |
250 | func (al AppList) Swap(i, j int) { al[i], al[j] = al[j], al[i] } | |
251 | func (al AppList) Less(i, j int) bool { return al[i].PackageName < al[j].PackageName } | |
252 | ||
253 | type ApkList []Apk | |
254 | ||
255 | func (al ApkList) Len() int { return len(al) } | |
256 | func (al ApkList) Swap(i, j int) { al[i], al[j] = al[j], al[i] } | |
257 | func (al ApkList) Less(i, j int) bool { return al[i].VersCode > al[j].VersCode } | |
258 | ||
259 | func LoadIndexJSON(r io.Reader) (*Index, error) { | |
260 | var index Index | |
261 | decoder := json.NewDecoder(r) | |
262 | if err := decoder.Decode(&index); err != nil { | |
263 | return nil, err | |
264 | } | |
265 | ||
266 | sort.Sort(AppList(index.Apps)) | |
267 | ||
268 | for i := range index.Apps { | |
269 | app := &index.Apps[i] | |
270 | english, enOK := app.Localized["en"] | |
271 | if !enOK { | |
272 | english, enOK = app.Localized["en-US"] | |
273 | } | |
274 | if app.Summary == "" && enOK { | |
275 | app.Summary = english.Summary | |
276 | } | |
277 | if app.Description == "" && enOK { | |
278 | app.Description = english.Description | |
279 | } | |
280 | app.Summary = strings.TrimSpace(app.Summary) | |
281 | sort.Sort(ApkList(index.Packages[app.PackageName])) | |
282 | for i := range index.Packages[app.PackageName] { | |
283 | apk := &index.Packages[app.PackageName][i] | |
284 | apk.AppID = app.PackageName | |
285 | apk.RepoURL = index.Repo.Address | |
286 | app.Apks = append(app.Apks, apk) | |
287 | } | |
288 | } | |
289 | return &index, nil | |
290 | } | |
291 | ||
292 | func (a *App) SuggestedApk(device *adb.Device) *Apk { | |
293 | for _, apk := range a.Apks { | |
294 | if a.SugVersCode >= apk.VersCode && apk.IsCompatible(device) { | |
295 | return apk | |
296 | } | |
297 | } | |
298 | // fall back to the first compatible apk | |
299 | for _, apk := range a.Apks { | |
300 | if apk.IsCompatible(device) { | |
301 | return apk | |
302 | } | |
303 | } | |
304 | return nil | |
305 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package fdroidcl | |
4 | ||
5 | import ( | |
6 | "bytes" | |
7 | "reflect" | |
8 | "strings" | |
9 | "testing" | |
10 | "time" | |
11 | ||
12 | "github.com/kr/pretty" | |
13 | ) | |
14 | ||
15 | func TestTextDesc(t *testing.T) { | |
16 | for _, c := range []struct { | |
17 | in string | |
18 | want string | |
19 | }{ | |
20 | { | |
21 | "Simple description.", | |
22 | "Simple description.", | |
23 | }, | |
24 | { | |
25 | "<p>Simple description.</p>", | |
26 | "Simple description.\n", | |
27 | }, | |
28 | { | |
29 | "<p>Multiple</p><p>Paragraphs</p>", | |
30 | "Multiple\n\nParagraphs\n", | |
31 | }, | |
32 | { | |
33 | "<p>Single, very long paragraph that is over 80 characters long and doesn't fit in a single line.</p>", | |
34 | "Single, very long paragraph that is over 80 characters long and doesn't fit in\na single line.\n", | |
35 | }, | |
36 | { | |
37 | "<p>Unordered list:</p><ul><li> Item</li><li> Another item</li></ul>", | |
38 | "Unordered list:\n\n * Item\n * Another item\n", | |
39 | }, | |
40 | { | |
41 | "<p>Link: <a href=\"http://foo.bar\">link title</a> text</p>", | |
42 | "Link: link title[0] text\n\n[0] http://foo.bar\n", | |
43 | }, | |
44 | { | |
45 | "<p>Links: <a href=\"foo\">foo1</a> <a href=\"bar\">bar1</a></p>", | |
46 | "Links: foo1[0] bar1[1]\n\n[0] foo\n[1] bar\n", | |
47 | }, | |
48 | } { | |
49 | app := App{Description: c.in} | |
50 | var b bytes.Buffer | |
51 | app.TextDesc(&b) | |
52 | got := b.String() | |
53 | if got != c.want { | |
54 | t.Fatalf("Unexpected description.\nGot:\n%s\nWant:\n%s\n", | |
55 | got, c.want) | |
56 | } | |
57 | } | |
58 | } | |
59 | ||
60 | func TestLoadIndexJSON(t *testing.T) { | |
61 | in := ` | |
62 | { | |
63 | "repo": { | |
64 | "name": "Foo", | |
65 | "version": 19, | |
66 | "timestamp": 1528184950000 | |
67 | }, | |
68 | "requests": { | |
69 | "install": [], | |
70 | "uninstall": [] | |
71 | }, | |
72 | "apps": [ | |
73 | { | |
74 | "packageName": "foo.bar", | |
75 | "name": "Foo bar", | |
76 | "categories": ["Cat1", "Cat2"], | |
77 | "added": 1443734950000, | |
78 | "suggestedVersionName": "1.0", | |
79 | "suggestedVersionCode": "1" | |
80 | }, | |
81 | { | |
82 | "packageName": "localized.app", | |
83 | "localized": { | |
84 | "en": { | |
85 | "summary": "summary in english\n" | |
86 | } | |
87 | } | |
88 | } | |
89 | ], | |
90 | "packages": { | |
91 | "foo.bar": [ | |
92 | { | |
93 | "versionName": "1.0", | |
94 | "versionCode": 1, | |
95 | "hash": "1e4c77d8c9fa03b3a9c42360dc55468f378bbacadeaf694daea304fe1a2750f4", | |
96 | "hashType": "sha256", | |
97 | "sig": "c0f3a6d46025bf41613c5e81781e517a", | |
98 | "signer": "573c2762a2ff87c4c1ef104b35147c8c316676e5d072ec636fc718f35df6cf22" | |
99 | } | |
100 | ] | |
101 | } | |
102 | } | |
103 | ` | |
104 | want := Index{ | |
105 | Repo: Repo{ | |
106 | Name: "Foo", | |
107 | Version: 19, | |
108 | Timestamp: UnixDate{time.Unix(1528184950, 0).UTC()}, | |
109 | }, | |
110 | Apps: []App{ | |
111 | { | |
112 | PackageName: "foo.bar", | |
113 | Name: "Foo bar", | |
114 | Categories: []string{"Cat1", "Cat2"}, | |
115 | Added: UnixDate{time.Unix(1443734950, 0).UTC()}, | |
116 | SugVersName: "1.0", | |
117 | SugVersCode: 1, | |
118 | Apks: []*Apk{nil}, | |
119 | }, | |
120 | { | |
121 | PackageName: "localized.app", | |
122 | Summary: "summary in english", | |
123 | Localized: map[string]Localization{ | |
124 | "en": {Summary: "summary in english\n"}, | |
125 | }, | |
126 | }, | |
127 | }, | |
128 | Packages: map[string][]Apk{"foo.bar": { | |
129 | { | |
130 | VersName: "1.0", | |
131 | VersCode: 1, | |
132 | Sig: HexVal{0xc0, 0xf3, 0xa6, 0xd4, 0x60, 0x25, 0xbf, 0x41, 0x61, 0x3c, 0x5e, 0x81, 0x78, 0x1e, 0x51, 0x7a}, | |
133 | Signer: HexVal{0x57, 0x3c, 0x27, 0x62, 0xa2, 0xff, 0x87, 0xc4, 0xc1, 0xef, 0x10, 0x4b, 0x35, 0x14, 0x7c, 0x8c, 0x31, 0x66, 0x76, 0xe5, 0xd0, 0x72, 0xec, 0x63, 0x6f, 0xc7, 0x18, 0xf3, 0x5d, 0xf6, 0xcf, 0x22}, | |
134 | Hash: HexVal{0x1e, 0x4c, 0x77, 0xd8, 0xc9, 0xfa, 0x3, 0xb3, 0xa9, 0xc4, 0x23, 0x60, 0xdc, 0x55, 0x46, 0x8f, 0x37, 0x8b, 0xba, 0xca, 0xde, 0xaf, 0x69, 0x4d, 0xae, 0xa3, 0x4, 0xfe, 0x1a, 0x27, 0x50, 0xf4}, | |
135 | HashType: "sha256", | |
136 | }, | |
137 | }}, | |
138 | } | |
139 | want.Apps[0].Apks[0] = &want.Packages["foo.bar"][0] | |
140 | r := strings.NewReader(in) | |
141 | index, err := LoadIndexJSON(r) | |
142 | if err != nil { | |
143 | t.Fatalf("Unexpected error: %v", err) | |
144 | } | |
145 | got := *index | |
146 | for i := range want.Apps { | |
147 | app := &want.Apps[i] | |
148 | for _, apk := range app.Apks { | |
149 | apk.AppID = app.PackageName | |
150 | } | |
151 | } | |
152 | if !reflect.DeepEqual(got, want) { | |
153 | t.Fatalf("Unexpected index.\n%s", | |
154 | strings.Join(pretty.Diff(want, got), "\n")) | |
155 | } | |
156 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "encoding/csv" | |
7 | "fmt" | |
8 | "io" | |
9 | "os" | |
10 | ||
11 | "mvdan.cc/fdroidcl/adb" | |
12 | "mvdan.cc/fdroidcl/fdroid" | |
13 | ) | |
14 | ||
15 | var cmdInstall = &Command{ | |
16 | UsageLine: "install [<appid...>]", | |
17 | Short: "Install or upgrade apps", | |
18 | Long: ` | |
19 | Install or upgrade apps. When given no arguments, it reads a comma-separated | |
20 | list of apps to install from standard input, like: | |
21 | ||
22 | packageName,versionCode,versionName | |
23 | foo.bar,120,1.2.0 | |
24 | `[1:], | |
25 | } | |
26 | ||
27 | var ( | |
28 | installUpdates = cmdInstall.Fset.Bool("u", false, "Upgrade all installed apps") | |
29 | installDryRun = cmdInstall.Fset.Bool("n", false, "Only print the operations that would be done") | |
30 | ) | |
31 | ||
32 | func init() { | |
33 | cmdInstall.Run = runInstall | |
34 | } | |
35 | ||
36 | func runInstall(args []string) error { | |
37 | if *installUpdates && len(args) > 0 { | |
38 | return fmt.Errorf("-u can only be used without arguments") | |
39 | } | |
40 | device, err := oneDevice() | |
41 | if err != nil { | |
42 | return err | |
43 | } | |
44 | inst, err := device.Installed() | |
45 | if err != nil { | |
46 | return err | |
47 | } | |
48 | ||
49 | if *installUpdates { | |
50 | apps, err := loadIndexes() | |
51 | if err != nil { | |
52 | return err | |
53 | } | |
54 | apps = filterAppsUpdates(apps, inst, device) | |
55 | if len(apps) == 0 { | |
56 | fmt.Fprintln(os.Stderr, "All apps up to date.") | |
57 | } | |
58 | return downloadAndDo(apps, device) | |
59 | } | |
60 | ||
61 | if len(args) == 0 { | |
62 | // The CSV input is as follows: | |
63 | // | |
64 | // packageName,versionCode,versionName | |
65 | // foo.bar,120,1.2.0 | |
66 | // ... | |
67 | ||
68 | r := csv.NewReader(os.Stdin) | |
69 | r.FieldsPerRecord = 3 | |
70 | r.Read() | |
71 | for { | |
72 | record, err := r.Read() | |
73 | if err == io.EOF { | |
74 | break | |
75 | } | |
76 | if err != nil { | |
77 | return fmt.Errorf("error parsing CSV: %v", err) | |
78 | } | |
79 | // convert "foo.bar,120" into "foo.bar:120" for findApps | |
80 | args = append(args, record[0]+":"+record[1]) | |
81 | } | |
82 | } | |
83 | ||
84 | apps, err := findApps(args) | |
85 | if err != nil { | |
86 | return err | |
87 | } | |
88 | var toInstall []fdroid.App | |
89 | for _, app := range apps { | |
90 | p, e := inst[app.PackageName] | |
91 | if !e { | |
92 | // installing an app from scratch | |
93 | toInstall = append(toInstall, app) | |
94 | continue | |
95 | } | |
96 | suggested := app.SuggestedApk(device) | |
97 | if suggested == nil { | |
98 | return fmt.Errorf("no suitable APKs found for %s", app.PackageName) | |
99 | } | |
100 | if p.VersCode >= suggested.VersCode { | |
101 | fmt.Printf("%s is up to date\n", app.PackageName) | |
102 | // app is already up to date | |
103 | continue | |
104 | } | |
105 | // upgrading an existing app | |
106 | toInstall = append(toInstall, app) | |
107 | } | |
108 | return downloadAndDo(toInstall, device) | |
109 | } | |
110 | ||
111 | func downloadAndDo(apps []fdroid.App, device *adb.Device) error { | |
112 | type downloaded struct { | |
113 | apk *fdroid.Apk | |
114 | path string | |
115 | } | |
116 | toInstall := make([]downloaded, len(apps)) | |
117 | for i, app := range apps { | |
118 | apk := app.SuggestedApk(device) | |
119 | if apk == nil { | |
120 | return fmt.Errorf("no suitable APKs found for %s", app.PackageName) | |
121 | } | |
122 | if *installDryRun { | |
123 | fmt.Printf("install %s:%d\n", app.PackageName, apk.VersCode) | |
124 | continue | |
125 | } | |
126 | path, err := downloadApk(apk) | |
127 | if err != nil { | |
128 | return err | |
129 | } | |
130 | toInstall[i] = downloaded{apk: apk, path: path} | |
131 | } | |
132 | if *installDryRun { | |
133 | return nil | |
134 | } | |
135 | for _, t := range toInstall { | |
136 | if err := installApk(device, t.apk, t.path); err != nil { | |
137 | return err | |
138 | } | |
139 | } | |
140 | return nil | |
141 | } | |
142 | ||
143 | func installApk(device *adb.Device, apk *fdroid.Apk, path string) error { | |
144 | fmt.Printf("Installing %s\n", apk.AppID) | |
145 | if err := device.Install(path); err != nil { | |
146 | return fmt.Errorf("could not install %s: %v", apk.AppID, err) | |
147 | } | |
148 | return nil | |
149 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package fdroidcl | |
4 | ||
5 | import ( | |
6 | "archive/zip" | |
7 | "errors" | |
8 | "io" | |
9 | ) | |
10 | ||
11 | const indexPath = "index-v1.json" | |
12 | ||
13 | var ErrNoIndex = errors.New("no json index found inside jar") | |
14 | ||
15 | func LoadIndexJar(r io.ReaderAt, size int64, pubkey []byte) (*Index, error) { | |
16 | reader, err := zip.NewReader(r, size) | |
17 | if err != nil { | |
18 | return nil, err | |
19 | } | |
20 | var index io.ReadCloser | |
21 | for _, f := range reader.File { | |
22 | if f.Name == indexPath { | |
23 | index, err = f.Open() | |
24 | if err != nil { | |
25 | return nil, err | |
26 | } | |
27 | break | |
28 | } | |
29 | } | |
30 | if index == nil { | |
31 | return nil, ErrNoIndex | |
32 | } | |
33 | defer index.Close() | |
34 | return LoadIndexJSON(index) | |
35 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | "os" | |
8 | "sort" | |
9 | ) | |
10 | ||
11 | var cmdList = &Command{ | |
12 | UsageLine: "list (categories)", | |
13 | Short: "List all known values of a kind", | |
14 | } | |
15 | ||
16 | func init() { | |
17 | cmdList.Run = runList | |
18 | } | |
19 | ||
20 | func runList(args []string) error { | |
21 | if len(args) != 1 { | |
22 | return fmt.Errorf("need exactly one argument") | |
23 | } | |
24 | apps, err := loadIndexes() | |
25 | if err != nil { | |
26 | return err | |
27 | } | |
28 | values := make(map[string]struct{}) | |
29 | switch args[0] { | |
30 | case "categories": | |
31 | for _, app := range apps { | |
32 | for _, c := range app.Categories { | |
33 | values[c] = struct{}{} | |
34 | } | |
35 | } | |
36 | default: | |
37 | return fmt.Errorf("invalid argument") | |
38 | } | |
39 | result := make([]string, 0, len(values)) | |
40 | for s := range values { | |
41 | result = append(result, s) | |
42 | } | |
43 | sort.Strings(result) | |
44 | for _, s := range result { | |
45 | fmt.Fprintln(os.Stdout, s) | |
46 | } | |
47 | return nil | |
48 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "encoding/json" | |
7 | "flag" | |
8 | "fmt" | |
9 | "os" | |
10 | "path/filepath" | |
11 | "strings" | |
12 | ||
13 | "mvdan.cc/fdroidcl/basedir" | |
14 | ) | |
15 | ||
16 | const cmdName = "fdroidcl" | |
17 | ||
18 | const version = "v0.5.0" | |
19 | ||
20 | func subdir(dir, name string) string { | |
21 | p := filepath.Join(dir, name) | |
22 | if err := os.MkdirAll(p, 0755); err != nil { | |
23 | fmt.Fprintf(os.Stderr, "Could not create dir '%s': %v\n", p, err) | |
24 | } | |
25 | return p | |
26 | } | |
27 | ||
28 | func mustCache() string { | |
29 | dir, err := os.UserCacheDir() | |
30 | if err != nil { | |
31 | fmt.Fprintln(os.Stderr, err) | |
32 | panic("TODO: return an error") | |
33 | } | |
34 | return subdir(dir, cmdName) | |
35 | } | |
36 | ||
37 | func mustData() string { | |
38 | dir := basedir.Data() | |
39 | if dir == "" { | |
40 | fmt.Fprintln(os.Stderr, "Could not determine data dir") | |
41 | panic("TODO: return an error") | |
42 | } | |
43 | return subdir(dir, cmdName) | |
44 | } | |
45 | ||
46 | func configPath() string { | |
47 | return filepath.Join(mustData(), "config.json") | |
48 | } | |
49 | ||
50 | type repo struct { | |
51 | ID string `json:"id"` | |
52 | URL string `json:"url"` | |
53 | Enabled bool `json:"enabled"` | |
54 | } | |
55 | ||
56 | type userConfig struct { | |
57 | Repos []repo `json:"repos"` | |
58 | } | |
59 | ||
60 | var config = userConfig{ | |
61 | Repos: []repo{ | |
62 | { | |
63 | ID: "f-droid", | |
64 | URL: "https://f-droid.org/repo", | |
65 | Enabled: true, | |
66 | }, | |
67 | { | |
68 | ID: "f-droid-archive", | |
69 | URL: "https://f-droid.org/archive", | |
70 | Enabled: false, | |
71 | }, | |
72 | }, | |
73 | } | |
74 | ||
75 | func readConfig() { | |
76 | f, err := os.Open(configPath()) | |
77 | if err != nil { | |
78 | return | |
79 | } | |
80 | defer f.Close() | |
81 | fileConfig := userConfig{} | |
82 | if err := json.NewDecoder(f).Decode(&fileConfig); err == nil { | |
83 | config = fileConfig | |
84 | } | |
85 | } | |
86 | ||
87 | // A Command is an implementation of a go command | |
88 | // like go build or go fix. | |
89 | type Command struct { | |
90 | // Run runs the command. | |
91 | // The args are the arguments after the command name. | |
92 | Run func(args []string) error | |
93 | ||
94 | // UsageLine is the one-line usage message. | |
95 | // The first word in the line is taken to be the command name. | |
96 | UsageLine string | |
97 | ||
98 | // Short is the short, single-line description. | |
99 | Short string | |
100 | ||
101 | // Long is an optional longer version of the Short description. | |
102 | Long string | |
103 | ||
104 | Fset flag.FlagSet | |
105 | } | |
106 | ||
107 | // Name returns the command's name: the first word in the usage line. | |
108 | func (c *Command) Name() string { | |
109 | name := c.UsageLine | |
110 | i := strings.Index(name, " ") | |
111 | if i >= 0 { | |
112 | name = name[:i] | |
113 | } | |
114 | return name | |
115 | } | |
116 | ||
117 | func (c *Command) usage() { | |
118 | fmt.Fprintf(os.Stderr, "usage: %s %s\n\n", cmdName, c.UsageLine) | |
119 | if c.Long == "" { | |
120 | fmt.Fprintf(os.Stderr, "%s.\n", c.Short) | |
121 | } else { | |
122 | fmt.Fprint(os.Stderr, c.Long) | |
123 | } | |
124 | anyFlags := false | |
125 | c.Fset.VisitAll(func(f *flag.Flag) { anyFlags = true }) | |
126 | if anyFlags { | |
127 | fmt.Fprintf(os.Stderr, "\nAvailable options:\n") | |
128 | c.Fset.PrintDefaults() | |
129 | } | |
130 | } | |
131 | ||
132 | func init() { | |
133 | flag.Usage = func() { | |
134 | fmt.Fprintf(os.Stderr, "usage: %s [-h] <command> [<args>]\n\n", cmdName) | |
135 | fmt.Fprintf(os.Stderr, "Available commands:\n") | |
136 | maxUsageLen := 0 | |
137 | for _, c := range commands { | |
138 | if len(c.UsageLine) > maxUsageLen { | |
139 | maxUsageLen = len(c.UsageLine) | |
140 | } | |
141 | } | |
142 | for _, c := range commands { | |
143 | fmt.Fprintf(os.Stderr, " %s%s %s\n", c.UsageLine, | |
144 | strings.Repeat(" ", maxUsageLen-len(c.UsageLine)), c.Short) | |
145 | } | |
146 | fmt.Fprintf(os.Stderr, ` | |
147 | An appid is just an app's unique package name. A specific version of an app can | |
148 | be selected by following the appid with a colon and the version code. The | |
149 | 'search' and 'show' commands can be used to find these strings. For example: | |
150 | ||
151 | $ fdroidcl search redreader | |
152 | $ fdroidcl show org.quantumbadger.redreader | |
153 | $ fdroidcl install org.quantumbadger.redreader:85 | |
154 | `) | |
155 | fmt.Fprintf(os.Stderr, "\nUse %s <command> -h for more information.\n", cmdName) | |
156 | } | |
157 | } | |
158 | ||
159 | // Commands lists the available commands. | |
160 | var commands = []*Command{ | |
161 | cmdUpdate, | |
162 | cmdSearch, | |
163 | cmdShow, | |
164 | cmdInstall, | |
165 | cmdUninstall, | |
166 | cmdDownload, | |
167 | cmdDevices, | |
168 | cmdList, | |
169 | cmdDefaults, | |
170 | cmdVersion, | |
171 | } | |
172 | ||
173 | var cmdVersion = &Command{ | |
174 | UsageLine: "version", | |
175 | Short: "Print version information", | |
176 | Run: func(args []string) error { | |
177 | if len(args) > 0 { | |
178 | return fmt.Errorf("no arguments allowed") | |
179 | } | |
180 | fmt.Println(version) | |
181 | return nil | |
182 | }, | |
183 | } | |
184 | ||
185 | func main() { | |
186 | os.Exit(main1()) | |
187 | } | |
188 | ||
189 | func main1() int { | |
190 | flag.Parse() | |
191 | args := flag.Args() | |
192 | ||
193 | if len(args) < 1 { | |
194 | flag.Usage() | |
195 | return 2 | |
196 | } | |
197 | ||
198 | cmdName := args[0] | |
199 | for _, cmd := range commands { | |
200 | if cmd.Name() != cmdName { | |
201 | continue | |
202 | } | |
203 | cmd.Fset.Init(cmdName, flag.ContinueOnError) | |
204 | cmd.Fset.Usage = cmd.usage | |
205 | if err := cmd.Fset.Parse(args[1:]); err != nil { | |
206 | if err != flag.ErrHelp { | |
207 | fmt.Fprintf(os.Stderr, "flag: %v\n", err) | |
208 | cmd.Fset.Usage() | |
209 | } | |
210 | return 2 | |
211 | } | |
212 | ||
213 | readConfig() | |
214 | if err := cmd.Run(cmd.Fset.Args()); err != nil { | |
215 | fmt.Fprintf(os.Stderr, "%s: %v\n", cmdName, err) | |
216 | return 1 | |
217 | } | |
218 | return 0 | |
219 | } | |
220 | fmt.Fprintf(os.Stderr, "Unrecognised command '%s'\n\n", cmdName) | |
221 | flag.Usage() | |
222 | return 2 | |
223 | } |
0 | package main | |
1 | ||
2 | import ( | |
3 | "fmt" | |
4 | "net" | |
5 | "net/http" | |
6 | "os" | |
7 | "path/filepath" | |
8 | "strconv" | |
9 | "strings" | |
10 | "testing" | |
11 | ||
12 | "github.com/rogpeppe/go-internal/testscript" | |
13 | "mvdan.cc/fdroidcl/adb" | |
14 | ) | |
15 | ||
16 | func TestMain(m *testing.M) { | |
17 | if os.Getenv("TESTSCRIPT_COMMAND") == "" { | |
18 | // start the static http server once | |
19 | path := filepath.Join("testdata", "staticrepo") | |
20 | fs := http.FileServer(http.Dir(path)) | |
21 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
22 | // The files are static, so add a unique etag for each file. | |
23 | w.Header().Set("Etag", strconv.Quote(r.URL.Path)) | |
24 | fs.ServeHTTP(w, r) | |
25 | }) | |
26 | ln, err := net.Listen("tcp", ":0") | |
27 | if err != nil { | |
28 | panic(err) | |
29 | } | |
30 | server := &http.Server{Handler: handler} | |
31 | go server.Serve(ln) | |
32 | // Save it to a global, which will be added as an env var to be | |
33 | // picked up by the children processes. | |
34 | staticRepoHost = ln.Addr().String() | |
35 | } else { | |
36 | httpClient.Transport = repoTransport{os.Getenv("REPO_HOST")} | |
37 | } | |
38 | ||
39 | os.Exit(testscript.RunMain(m, map[string]func() int{ | |
40 | "fdroidcl": main1, | |
41 | })) | |
42 | } | |
43 | ||
44 | type repoTransport struct { | |
45 | repoHost string | |
46 | } | |
47 | ||
48 | func (t repoTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |
49 | // replace https://f-droid.org/repo/foo with http://localhost:1234/foo | |
50 | req.URL.Scheme = "http" | |
51 | req.URL.Host = t.repoHost | |
52 | req.URL.Path = strings.TrimPrefix(req.URL.Path, "/repo") | |
53 | return http.DefaultClient.Do(req) | |
54 | } | |
55 | ||
56 | var staticRepoHost string | |
57 | ||
58 | func TestScripts(t *testing.T) { | |
59 | t.Parallel() | |
60 | testscript.Run(t, testscript.Params{ | |
61 | Dir: filepath.Join("testdata", "scripts"), | |
62 | Setup: func(e *testscript.Env) error { | |
63 | home := e.WorkDir + "/home" | |
64 | if err := os.MkdirAll(home, 0777); err != nil { | |
65 | return err | |
66 | } | |
67 | e.Vars = append(e.Vars, "HOME="+home) | |
68 | e.Vars = append(e.Vars, "REPO_HOST="+staticRepoHost) | |
69 | return nil | |
70 | }, | |
71 | Condition: func(cond string) (bool, error) { | |
72 | switch cond { | |
73 | case "device": | |
74 | devices, err := adb.Devices() | |
75 | return err == nil && len(devices) == 1, nil | |
76 | } | |
77 | return false, fmt.Errorf("unknown condition %q", cond) | |
78 | }, | |
79 | }) | |
80 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | "os" | |
8 | "regexp" | |
9 | "sort" | |
10 | "strings" | |
11 | "time" | |
12 | ||
13 | "mvdan.cc/fdroidcl/adb" | |
14 | "mvdan.cc/fdroidcl/fdroid" | |
15 | ) | |
16 | ||
17 | var cmdSearch = &Command{ | |
18 | UsageLine: "search [<regexp...>]", | |
19 | Short: "Search available apps", | |
20 | } | |
21 | ||
22 | var ( | |
23 | searchQuiet = cmdSearch.Fset.Bool("q", false, "Print package names only") | |
24 | searchInstalled = cmdSearch.Fset.Bool("i", false, "Filter installed apps") | |
25 | searchUpdates = cmdSearch.Fset.Bool("u", false, "Filter apps with updates") | |
26 | searchDays = cmdSearch.Fset.Int("d", 0, "Select apps last updated in the last <n> days; a negative value drops them instead") | |
27 | searchCategory = cmdSearch.Fset.String("c", "", "Filter apps by category") | |
28 | searchSortBy = cmdSearch.Fset.String("o", "", "Sort order (added, updated)") | |
29 | ) | |
30 | ||
31 | func init() { | |
32 | cmdSearch.Run = runSearch | |
33 | } | |
34 | ||
35 | func runSearch(args []string) error { | |
36 | if *searchInstalled && *searchUpdates { | |
37 | return fmt.Errorf("-i is redundant if -u is specified") | |
38 | } | |
39 | sfunc, err := sortFunc(*searchSortBy) | |
40 | if err != nil { | |
41 | return err | |
42 | } | |
43 | apps, err := loadIndexes() | |
44 | if err != nil { | |
45 | return err | |
46 | } | |
47 | if len(apps) > 0 && *searchCategory != "" { | |
48 | apps = filterAppsCategory(apps, *searchCategory) | |
49 | if apps == nil { | |
50 | return fmt.Errorf("no such category: %s", *searchCategory) | |
51 | } | |
52 | } | |
53 | if len(apps) > 0 && len(args) > 0 { | |
54 | apps = filterAppsSearch(apps, args) | |
55 | } | |
56 | var device *adb.Device | |
57 | var inst map[string]adb.Package | |
58 | if *searchInstalled || *searchUpdates { | |
59 | if device, err = oneDevice(); err != nil { | |
60 | return err | |
61 | } | |
62 | if inst, err = device.Installed(); err != nil { | |
63 | return err | |
64 | } | |
65 | } | |
66 | if len(apps) > 0 && *searchInstalled { | |
67 | apps = filterAppsInstalled(apps, inst) | |
68 | } | |
69 | if len(apps) > 0 && *searchUpdates { | |
70 | apps = filterAppsUpdates(apps, inst, device) | |
71 | } | |
72 | if len(apps) > 0 && *searchDays != 0 { | |
73 | apps = filterAppsLastUpdated(apps, *searchDays) | |
74 | } | |
75 | if sfunc != nil { | |
76 | apps = sortApps(apps, sfunc) | |
77 | } | |
78 | if *searchQuiet { | |
79 | for _, app := range apps { | |
80 | fmt.Fprintln(os.Stdout, app.PackageName) | |
81 | } | |
82 | } else { | |
83 | printApps(apps, inst, device) | |
84 | } | |
85 | return nil | |
86 | } | |
87 | ||
88 | func filterAppsSearch(apps []fdroid.App, terms []string) []fdroid.App { | |
89 | regexes := make([]*regexp.Regexp, len(terms)) | |
90 | for i, term := range terms { | |
91 | regexes[i] = regexp.MustCompile(term) | |
92 | } | |
93 | var result []fdroid.App | |
94 | for _, app := range apps { | |
95 | fields := []string{ | |
96 | strings.ToLower(app.PackageName), | |
97 | strings.ToLower(app.Name), | |
98 | strings.ToLower(app.Summary), | |
99 | strings.ToLower(app.Description), | |
100 | } | |
101 | if !appMatches(fields, regexes) { | |
102 | continue | |
103 | } | |
104 | result = append(result, app) | |
105 | } | |
106 | return result | |
107 | } | |
108 | ||
109 | func appMatches(fields []string, regexes []*regexp.Regexp) bool { | |
110 | fieldLoop: | |
111 | for _, field := range fields { | |
112 | for _, regex := range regexes { | |
113 | if !regex.MatchString(field) { | |
114 | continue fieldLoop | |
115 | } | |
116 | } | |
117 | return true | |
118 | } | |
119 | return false | |
120 | } | |
121 | ||
122 | func printApps(apps []fdroid.App, inst map[string]adb.Package, device *adb.Device) { | |
123 | maxIDLen := 0 | |
124 | for _, app := range apps { | |
125 | if len(app.PackageName) > maxIDLen { | |
126 | maxIDLen = len(app.PackageName) | |
127 | } | |
128 | } | |
129 | for _, app := range apps { | |
130 | var pkg *adb.Package | |
131 | p, e := inst[app.PackageName] | |
132 | if e { | |
133 | pkg = &p | |
134 | } | |
135 | printApp(app, maxIDLen, pkg, device) | |
136 | } | |
137 | } | |
138 | ||
139 | func descVersion(app fdroid.App, inst *adb.Package, device *adb.Device) string { | |
140 | if inst != nil { | |
141 | suggested := app.SuggestedApk(device) | |
142 | if suggested != nil && inst.VersCode < suggested.VersCode { | |
143 | return fmt.Sprintf("%s (%d) -> %s (%d)", inst.VersName, inst.VersCode, | |
144 | suggested.VersName, suggested.VersCode) | |
145 | } | |
146 | return fmt.Sprintf("%s (%d)", inst.VersName, inst.VersCode) | |
147 | } | |
148 | return fmt.Sprintf("%s (%d)", app.SugVersName, app.SugVersCode) | |
149 | } | |
150 | ||
151 | func printApp(app fdroid.App, IDLen int, inst *adb.Package, device *adb.Device) { | |
152 | fmt.Printf("%s%s %s - %s\n", app.PackageName, strings.Repeat(" ", IDLen-len(app.PackageName)), | |
153 | app.Name, descVersion(app, inst, device)) | |
154 | fmt.Printf(" %s\n", app.Summary) | |
155 | } | |
156 | ||
157 | func filterAppsInstalled(apps []fdroid.App, inst map[string]adb.Package) []fdroid.App { | |
158 | var result []fdroid.App | |
159 | for _, app := range apps { | |
160 | if _, e := inst[app.PackageName]; !e { | |
161 | continue | |
162 | } | |
163 | result = append(result, app) | |
164 | } | |
165 | return result | |
166 | } | |
167 | ||
168 | func filterAppsUpdates(apps []fdroid.App, inst map[string]adb.Package, device *adb.Device) []fdroid.App { | |
169 | var result []fdroid.App | |
170 | for _, app := range apps { | |
171 | p, e := inst[app.PackageName] | |
172 | if !e { | |
173 | continue | |
174 | } | |
175 | suggested := app.SuggestedApk(device) | |
176 | if suggested == nil { | |
177 | continue | |
178 | } | |
179 | if p.VersCode >= suggested.VersCode { | |
180 | continue | |
181 | } | |
182 | result = append(result, app) | |
183 | } | |
184 | return result | |
185 | } | |
186 | ||
187 | func filterAppsLastUpdated(apps []fdroid.App, days int) []fdroid.App { | |
188 | var result []fdroid.App | |
189 | newer := true | |
190 | if days < 0 { | |
191 | days = -days | |
192 | newer = false | |
193 | } | |
194 | date := time.Now().Truncate(24*time.Hour).AddDate(0, 0, 1-days) | |
195 | for _, app := range apps { | |
196 | if app.Updated.Before(date) == newer { | |
197 | continue | |
198 | } | |
199 | result = append(result, app) | |
200 | } | |
201 | return result | |
202 | } | |
203 | ||
204 | func contains(l []string, s string) bool { | |
205 | for _, s1 := range l { | |
206 | if s1 == s { | |
207 | return true | |
208 | } | |
209 | } | |
210 | return false | |
211 | } | |
212 | ||
213 | func filterAppsCategory(apps []fdroid.App, categ string) []fdroid.App { | |
214 | var result []fdroid.App | |
215 | for _, app := range apps { | |
216 | if !contains(app.Categories, categ) { | |
217 | continue | |
218 | } | |
219 | result = append(result, app) | |
220 | } | |
221 | return result | |
222 | } | |
223 | ||
224 | func cmpAdded(a, b *fdroid.App) bool { | |
225 | return a.Added.Before(b.Added.Time) | |
226 | } | |
227 | ||
228 | func cmpUpdated(a, b *fdroid.App) bool { | |
229 | return a.Updated.Before(b.Updated.Time) | |
230 | } | |
231 | ||
232 | func sortFunc(sortBy string) (func(a, b *fdroid.App) bool, error) { | |
233 | switch sortBy { | |
234 | case "added": | |
235 | return cmpAdded, nil | |
236 | case "updated": | |
237 | return cmpUpdated, nil | |
238 | case "": | |
239 | return nil, nil | |
240 | } | |
241 | return nil, fmt.Errorf("unknown sort order: %s", sortBy) | |
242 | } | |
243 | ||
244 | type appList struct { | |
245 | l []fdroid.App | |
246 | f func(a, b *fdroid.App) bool | |
247 | } | |
248 | ||
249 | func (al appList) Len() int { return len(al.l) } | |
250 | func (al appList) Swap(i, j int) { al.l[i], al.l[j] = al.l[j], al.l[i] } | |
251 | func (al appList) Less(i, j int) bool { return al.f(&al.l[i], &al.l[j]) } | |
252 | ||
253 | func sortApps(apps []fdroid.App, f func(a, b *fdroid.App) bool) []fdroid.App { | |
254 | sort.Sort(appList{l: apps, f: f}) | |
255 | return apps | |
256 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "fmt" | |
7 | "os" | |
8 | "strconv" | |
9 | "strings" | |
10 | ||
11 | "mvdan.cc/fdroidcl/fdroid" | |
12 | ) | |
13 | ||
14 | var cmdShow = &Command{ | |
15 | UsageLine: "show <appid...>", | |
16 | Short: "Show detailed info about apps", | |
17 | } | |
18 | ||
19 | func init() { | |
20 | cmdShow.Run = runShow | |
21 | } | |
22 | ||
23 | func runShow(args []string) error { | |
24 | if len(args) < 1 { | |
25 | return fmt.Errorf("no package names given") | |
26 | } | |
27 | apps, err := findApps(args) | |
28 | if err != nil { | |
29 | return err | |
30 | } | |
31 | for i, app := range apps { | |
32 | if i > 0 { | |
33 | fmt.Printf("\n--\n\n") | |
34 | } | |
35 | printAppDetailed(app) | |
36 | } | |
37 | return nil | |
38 | } | |
39 | ||
40 | func appsMap(apps []fdroid.App) map[string]*fdroid.App { | |
41 | m := make(map[string]*fdroid.App, len(apps)) | |
42 | for i := range apps { | |
43 | app := &apps[i] | |
44 | m[app.PackageName] = app | |
45 | } | |
46 | return m | |
47 | } | |
48 | ||
49 | func findApps(ids []string) ([]fdroid.App, error) { | |
50 | apps, err := loadIndexes() | |
51 | if err != nil { | |
52 | return nil, err | |
53 | } | |
54 | byId := appsMap(apps) | |
55 | result := make([]fdroid.App, len(ids)) | |
56 | for i, id := range ids { | |
57 | var vcode = -1 | |
58 | j := strings.Index(id, ":") | |
59 | if j > -1 { | |
60 | var err error | |
61 | vcode, err = strconv.Atoi(id[j+1:]) | |
62 | if err != nil { | |
63 | return nil, fmt.Errorf("could not parse version code from '%s'", id) | |
64 | } | |
65 | id = id[:j] | |
66 | } | |
67 | ||
68 | app, e := byId[id] | |
69 | if !e { | |
70 | return nil, fmt.Errorf("could not find app with ID '%s'", id) | |
71 | } | |
72 | ||
73 | if vcode > -1 { | |
74 | found := false | |
75 | for _, apk := range app.Apks { | |
76 | if apk.VersCode == vcode { | |
77 | app.Apks = []*fdroid.Apk{apk} | |
78 | found = true | |
79 | } | |
80 | } | |
81 | if !found { | |
82 | return nil, fmt.Errorf("could not find version %d for app with ID '%s'", vcode, id) | |
83 | } | |
84 | } | |
85 | result[i] = *app | |
86 | } | |
87 | return result, nil | |
88 | } | |
89 | ||
90 | func printAppDetailed(app fdroid.App) { | |
91 | fmt.Printf("Package : %s\n", app.PackageName) | |
92 | fmt.Printf("Name : %s\n", app.Name) | |
93 | fmt.Printf("Summary : %s\n", app.Summary) | |
94 | fmt.Printf("Added : %s\n", app.Added.String()) | |
95 | fmt.Printf("Last Updated : %s\n", app.Updated.String()) | |
96 | fmt.Printf("Version : %s (%d)\n", app.SugVersName, app.SugVersCode) | |
97 | fmt.Printf("License : %s\n", app.License) | |
98 | if app.Categories != nil { | |
99 | fmt.Printf("Categories : %s\n", strings.Join(app.Categories, ", ")) | |
100 | } | |
101 | if app.Website != "" { | |
102 | fmt.Printf("Website : %s\n", app.Website) | |
103 | } | |
104 | if app.SourceCode != "" { | |
105 | fmt.Printf("Source Code : %s\n", app.SourceCode) | |
106 | } | |
107 | if app.IssueTracker != "" { | |
108 | fmt.Printf("Issue Tracker : %s\n", app.IssueTracker) | |
109 | } | |
110 | if app.Changelog != "" { | |
111 | fmt.Printf("Changelog : %s\n", app.Changelog) | |
112 | } | |
113 | if app.Donate != "" { | |
114 | fmt.Printf("Donate : %s\n", app.Donate) | |
115 | } | |
116 | if app.Bitcoin != "" { | |
117 | fmt.Printf("Bitcoin : bitcoin:%s\n", app.Bitcoin) | |
118 | } | |
119 | if app.Litecoin != "" { | |
120 | fmt.Printf("Litecoin : litecoin:%s\n", app.Litecoin) | |
121 | } | |
122 | if app.FlattrID != "" { | |
123 | fmt.Printf("Flattr : https://flattr.com/thing/%s\n", app.FlattrID) | |
124 | } | |
125 | fmt.Println() | |
126 | fmt.Println("Description :") | |
127 | fmt.Println() | |
128 | app.TextDesc(os.Stdout) | |
129 | fmt.Println() | |
130 | fmt.Println("Available Versions :") | |
131 | for _, apk := range app.Apks { | |
132 | fmt.Println() | |
133 | fmt.Printf(" Version : %s (%d)\n", apk.VersName, apk.VersCode) | |
134 | fmt.Printf(" Size : %d\n", apk.Size) | |
135 | fmt.Printf(" MinSdk : %d\n", apk.MinSdk) | |
136 | if apk.MaxSdk > 0 { | |
137 | fmt.Printf(" MaxSdk : %d\n", apk.MaxSdk) | |
138 | } | |
139 | if apk.ABIs != nil { | |
140 | fmt.Printf(" ABIs : %s\n", strings.Join(apk.ABIs, ", ")) | |
141 | } | |
142 | if apk.Perms != nil { | |
143 | fmt.Printf(" Perms : %s\n", strings.Join(apk.Perms, ", ")) | |
144 | } | |
145 | } | |
146 | } |
0 | env HOME=$WORK/home | |
1 | ||
2 | ! fdroidcl | |
3 | stderr '^usage: fdroidcl \[-h' | |
4 | ||
5 | ! fdroidcl -h | |
6 | stderr '^usage: fdroidcl \[-h' | |
7 | ! stderr 'test\.' # don't include flags from testing | |
8 | ! stderr 'command not specified' | |
9 | ! stdout . | |
10 | ||
11 | fdroidcl version | |
12 | stdout '^v0\.5' | |
13 | ||
14 | ! fdroidcl -badflag -- somepkg | |
15 | stderr '-badflag' | |
16 | stderr '^usage: fdroidcl \[-h' | |
17 | ||
18 | ! fdroidcl search -h | |
19 | stderr '^usage: fdroidcl search .*regexp' | |
20 | stderr '^Search available apps.' | |
21 | stderr '-i.*Filter installed apps' | |
22 | ||
23 | ! fdroidcl install -h | |
24 | stderr 'When given no arguments' | |
25 | ||
26 | ! fdroidcl | |
27 | ||
28 | ! fdroidcl install -u some.app | |
29 | stderr 'without arguments' |
0 | env HOME=$WORK/home | |
1 | ||
2 | [!device] skip | |
3 | ||
4 | fdroidcl update | |
5 | ||
6 | # we have exactly one device | |
7 | fdroidcl devices | |
8 | stdout . | |
9 | ||
10 | # We'll use a really small app, red_screen, to test interacting with a device. | |
11 | # Besides being tiny, it requires no permissions, is compatible with virtually | |
12 | # every device, and cannot hold data. So it's fine to uninstall. | |
13 | ||
14 | # ensure that the app isn't installed to begin with | |
15 | ! fdroidcl uninstall org.vi_server.red_screen | |
16 | stderr 'not installed' | |
17 | ||
18 | # missing app is not installed | |
19 | fdroidcl search -i -q | |
20 | ! stdout 'org\.vi_server\.red_screen' | |
21 | ||
22 | # missing app is not upgradable | |
23 | fdroidcl search -u -q | |
24 | ! stdout 'org\.vi_server\.red_screen' | |
25 | ||
26 | # install via csv input works as expected | |
27 | stdin applist.csv | |
28 | fdroidcl install -n | |
29 | stdout 'install org\.vi_server\.red_screen:1' | |
30 | ||
31 | # install version code 1 | |
32 | fdroidcl install org.vi_server.red_screen:1 | |
33 | stdout 'Downloading.*red_screen_1.apk' | |
34 | stdout 'done' | |
35 | stdout 'Installing' | |
36 | ||
37 | # app shows up as installed and upgradable | |
38 | fdroidcl search -i -q | |
39 | stdout 'org\.vi_server\.red_screen' | |
40 | fdroidcl search -u -q | |
41 | stdout 'org\.vi_server\.red_screen' | |
42 | fdroidcl install -u -n | |
43 | stdout 'install org\.vi_server\.red_screen:2' | |
44 | ||
45 | # upgrade app to version code 2 | |
46 | fdroidcl install org.vi_server.red_screen | |
47 | stdout 'Downloading.*red_screen_2.apk' | |
48 | stdout 'done' | |
49 | stdout 'Installing' | |
50 | ||
51 | # app does not show up as upgradable | |
52 | fdroidcl search -u -q | |
53 | ! stdout 'org\.vi_server\.red_screen' | |
54 | fdroidcl install -u -n | |
55 | ! stdout 'install org\.vi_server\.red_screen:2' | |
56 | ||
57 | # nothing to install or upgrade | |
58 | fdroidcl install org.vi_server.red_screen | |
59 | ! stdout 'Downloading' | |
60 | stdout 'is up to date' | |
61 | ||
62 | # uninstall an app that exists | |
63 | fdroidcl uninstall org.vi_server.red_screen | |
64 | ||
65 | -- applist.csv -- | |
66 | packageName,versionCode,versionName | |
67 | org.vi_server.red_screen,1,1.0 |
0 | env HOME=$WORK/home | |
1 | ||
2 | fdroidcl update | |
3 | ||
4 | fdroidcl download org.vi_server.red_screen | |
5 | stdout 'Downloading.*red_screen_2.apk' | |
6 | stdout 'done' | |
7 | stdout 'APK available in .*fdroidcl.*apks.*red_screen_2.apk$' |
0 | env HOME=$WORK/home | |
1 | ||
2 | fdroidcl update | |
3 | ||
4 | fdroidcl search | |
5 | stdout 'F-Droid' | |
6 | ||
7 | fdroidcl search fdroid.fdroid | |
8 | stdout 'F-Droid' | |
9 | ||
10 | fdroidcl search nomatches | |
11 | ! stdout . | |
12 | ||
13 | fdroidcl search -q fdroid.fdroid | |
14 | ! stdout ' ' |
0 | env HOME=$WORK/home | |
1 | ||
2 | fdroidcl update | |
3 | ||
4 | fdroidcl show org.fdroid.fdroid | |
5 | stdout 'fdroid/fdroidclient' | |
6 | ! stdout 'fdroid/privileged-extension' | |
7 | ||
8 | fdroidcl show org.fdroid.fdroid org.fdroid.fdroid.privileged | |
9 | stdout 'fdroid/fdroidclient' | |
10 | stdout 'fdroid/privileged-extension' | |
11 | ||
12 | fdroidcl show org.pocketworkstation.pckeyboard info.metadude.android.bitsundbaeume.schedule | |
13 | ! stdout '&apos' | |
14 | ! stdout '&' | |
15 | stdout 'Name.*Hacker''s Keyboard' | |
16 | stdout 'Version.*Bits & Bäume' |
0 | env HOME=$WORK/home | |
1 | ||
2 | ! fdroidcl search | |
3 | stderr 'index does not exist' | |
4 | ||
5 | fdroidcl update | |
6 | stdout 'done' | |
7 | ||
8 | fdroidcl update | |
9 | stdout 'not modified' |
Binary diff not shown
Binary diff not shown
Binary diff not shown
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "errors" | |
7 | "fmt" | |
8 | ) | |
9 | ||
10 | var cmdUninstall = &Command{ | |
11 | UsageLine: "uninstall <appid...>", | |
12 | Short: "Uninstall an app", | |
13 | } | |
14 | ||
15 | func init() { | |
16 | cmdUninstall.Run = runUninstall | |
17 | } | |
18 | ||
19 | func runUninstall(args []string) error { | |
20 | if len(args) < 1 { | |
21 | return fmt.Errorf("no package names given") | |
22 | } | |
23 | device, err := oneDevice() | |
24 | if err != nil { | |
25 | return err | |
26 | } | |
27 | inst, err := device.Installed() | |
28 | if err != nil { | |
29 | return err | |
30 | } | |
31 | for _, id := range args { | |
32 | var err error | |
33 | fmt.Printf("Uninstalling %s\n", id) | |
34 | if _, installed := inst[id]; installed { | |
35 | err = device.Uninstall(id) | |
36 | } else { | |
37 | err = errors.New("not installed") | |
38 | } | |
39 | if err != nil { | |
40 | return fmt.Errorf("could not uninstall %s: %v", id, err) | |
41 | } | |
42 | } | |
43 | return nil | |
44 | } |
0 | // Copyright (c) 2015, Daniel Martà <mvdan@mvdan.cc> | |
1 | // See LICENSE for licensing information | |
2 | ||
3 | package main | |
4 | ||
5 | import ( | |
6 | "bytes" | |
7 | "crypto/sha256" | |
8 | "encoding/gob" | |
9 | "fmt" | |
10 | "io" | |
11 | "io/ioutil" | |
12 | "net/http" | |
13 | "os" | |
14 | "path/filepath" | |
15 | "sort" | |
16 | ||
17 | "mvdan.cc/fdroidcl/fdroid" | |
18 | ) | |
19 | ||
20 | var cmdUpdate = &Command{ | |
21 | UsageLine: "update", | |
22 | Short: "Update the index", | |
23 | } | |
24 | ||
25 | func init() { | |
26 | cmdUpdate.Run = runUpdate | |
27 | } | |
28 | ||
29 | func runUpdate(args []string) error { | |
30 | anyModified := false | |
31 | for _, r := range config.Repos { | |
32 | if !r.Enabled { | |
33 | continue | |
34 | } | |
35 | if err := r.updateIndex(); err == errNotModified { | |
36 | } else if err != nil { | |
37 | return fmt.Errorf("could not update index: %v", err) | |
38 | } else { | |
39 | anyModified = true | |
40 | } | |
41 | } | |
42 | if anyModified { | |
43 | cachePath := filepath.Join(mustCache(), "cache-gob") | |
44 | os.Remove(cachePath) | |
45 | } | |
46 | return nil | |
47 | } | |
48 | ||
49 | const jarFile = "index-v1.jar" | |
50 | ||
51 | func (r *repo) updateIndex() error { | |
52 | url := fmt.Sprintf("%s/%s", r.URL, jarFile) | |
53 | return downloadEtag(url, indexPath(r.ID), nil) | |
54 | } | |
55 | ||
56 | func (r *repo) loadIndex() (*fdroid.Index, error) { | |
57 | p := indexPath(r.ID) | |
58 | f, err := os.Open(p) | |
59 | if os.IsNotExist(err) { | |
60 | return nil, fmt.Errorf("index does not exist; try 'fdroidcl update'") | |
61 | } else if err != nil { | |
62 | return nil, fmt.Errorf("could not open index: %v", err) | |
63 | } | |
64 | stat, err := f.Stat() | |
65 | if err != nil { | |
66 | return nil, fmt.Errorf("could not stat index: %v", err) | |
67 | } | |
68 | return fdroid.LoadIndexJar(f, stat.Size(), nil) | |
69 | } | |
70 | ||
71 | func respEtag(resp *http.Response) string { | |
72 | etags, e := resp.Header["Etag"] | |
73 | if !e || len(etags) == 0 { | |
74 | return "" | |
75 | } | |
76 | return etags[0] | |
77 | } | |
78 | ||
79 | var errNotModified = fmt.Errorf("not modified") | |
80 | ||
81 | var httpClient = &http.Client{} | |
82 | ||
83 | func downloadEtag(url, path string, sum []byte) error { | |
84 | fmt.Printf("Downloading %s... ", url) | |
85 | defer fmt.Println() | |
86 | req, err := http.NewRequest("GET", url, nil) | |
87 | if err != nil { | |
88 | return err | |
89 | } | |
90 | ||
91 | etagPath := path + "-etag" | |
92 | if _, err := os.Stat(path); err == nil { | |
93 | etag, _ := ioutil.ReadFile(etagPath) | |
94 | req.Header.Add("If-None-Match", string(etag)) | |
95 | } | |
96 | ||
97 | resp, err := httpClient.Do(req) | |
98 | if err != nil { | |
99 | return err | |
100 | } | |
101 | defer resp.Body.Close() | |
102 | if resp.StatusCode >= 400 { | |
103 | return fmt.Errorf("download failed: %d %s", | |
104 | resp.StatusCode, http.StatusText(resp.StatusCode)) | |
105 | } | |
106 | if resp.StatusCode == http.StatusNotModified { | |
107 | fmt.Printf("not modified") | |
108 | return errNotModified | |
109 | } | |
110 | f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) | |
111 | if err != nil { | |
112 | return err | |
113 | } | |
114 | defer f.Close() | |
115 | if sum == nil { | |
116 | _, err := io.Copy(f, resp.Body) | |
117 | if err != nil { | |
118 | return err | |
119 | } | |
120 | } else { | |
121 | data, err := ioutil.ReadAll(resp.Body) | |
122 | if err != nil { | |
123 | return err | |
124 | } | |
125 | got := sha256.Sum256(data) | |
126 | if !bytes.Equal(sum, got[:]) { | |
127 | return fmt.Errorf("sha256 mismatch") | |
128 | } | |
129 | if _, err := f.Write(data); err != nil { | |
130 | return err | |
131 | } | |
132 | } | |
133 | if err := ioutil.WriteFile(etagPath, []byte(respEtag(resp)), 0644); err != nil { | |
134 | return err | |
135 | } | |
136 | fmt.Printf("done") | |
137 | return nil | |
138 | } | |
139 | ||
140 | func indexPath(name string) string { | |
141 | return filepath.Join(mustData(), name+".jar") | |
142 | } | |
143 | ||
144 | const cacheVersion = 2 | |
145 | ||
146 | type cache struct { | |
147 | Version int | |
148 | Apps []fdroid.App | |
149 | } | |
150 | ||
151 | type apkPtrList []*fdroid.Apk | |
152 | ||
153 | func (al apkPtrList) Len() int { return len(al) } | |
154 | func (al apkPtrList) Swap(i, j int) { al[i], al[j] = al[j], al[i] } | |
155 | func (al apkPtrList) Less(i, j int) bool { return al[i].VersCode > al[j].VersCode } | |
156 | ||
157 | func loadIndexes() ([]fdroid.App, error) { | |
158 | cachePath := filepath.Join(mustCache(), "cache-gob") | |
159 | if f, err := os.Open(cachePath); err == nil { | |
160 | defer f.Close() | |
161 | var c cache | |
162 | if err := gob.NewDecoder(f).Decode(&c); err == nil && c.Version == cacheVersion { | |
163 | return c.Apps, nil | |
164 | } | |
165 | } | |
166 | m := make(map[string]*fdroid.App) | |
167 | for _, r := range config.Repos { | |
168 | if !r.Enabled { | |
169 | continue | |
170 | } | |
171 | index, err := r.loadIndex() | |
172 | if err != nil { | |
173 | return nil, fmt.Errorf("error while loading %s: %v", r.ID, err) | |
174 | } | |
175 | for i := range index.Apps { | |
176 | app := index.Apps[i] | |
177 | orig, e := m[app.PackageName] | |
178 | if !e { | |
179 | m[app.PackageName] = &app | |
180 | continue | |
181 | } | |
182 | apks := append(orig.Apks, app.Apks...) | |
183 | // We use a stable sort so that repository order | |
184 | // (priority) is preserved amongst apks with the same | |
185 | // vercode on apps | |
186 | sort.Stable(apkPtrList(apks)) | |
187 | m[app.PackageName].Apks = apks | |
188 | } | |
189 | } | |
190 | apps := make([]fdroid.App, 0, len(m)) | |
191 | for _, a := range m { | |
192 | apps = append(apps, *a) | |
193 | } | |
194 | sort.Sort(fdroid.AppList(apps)) | |
195 | if f, err := os.Create(cachePath); err == nil { | |
196 | defer f.Close() | |
197 | gob.NewEncoder(f).Encode(cache{ | |
198 | Version: cacheVersion, | |
199 | Apps: apps, | |
200 | }) | |
201 | } | |
202 | return apps, nil | |
203 | } |