Codebase list fdroidcl / 796a7e1
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
49 changed file(s) with 2091 addition(s) and 1961 deletion(s). Raw diff Collapse all Expand all
44 *.xml
55 *.jar
66 *-etag
7
8 # Allow testdata/staticrepo
9 !/testdata/staticrepo/*
00 language: go
11
22 go:
3 - 1.9.x
4 - 1.10.x
3 - 1.11.x
4 - 1.12beta2
55
6 go_import_path: mvdan.cc/fdroidcl
6 env:
7 - GO111MODULE=on
8
9 install: true
10
11 script:
12 - go test ./...
22 [![GoDoc](https://godoc.org/github.com/mvdan/fdroidcl?status.svg)](https://godoc.org/mvdan.cc/fdroidcl)
33 [![Build Status](https://travis-ci.org/mvdan/fdroidcl.svg?branch=master)](https://travis-ci.org/mvdan/fdroidcl)
44
5 [F-Droid](https://f-droid.org/) desktop client.
5 [F-Droid](https://f-droid.org/) desktop client. Requires Go 1.11 or later.
66
7 go get -u mvdan.cc/fdroidcl/cmd/fdroidcl
7 go get -u mvdan.cc/fdroidcl
88
99 While the Android client integrates with the system with regular update checks
1010 and notifications, this is a simple command line client that talks to connected
2727 ### Commands
2828
2929 update Update the index
30 search <regexp...> Search available apps
30 search [<regexp...>] Search available apps
3131 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
3235 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
3637 defaults Reset to the default settings
38 version Print version information
3739
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
4048
4149 ### Config
4250
88 "path/filepath"
99 )
1010
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.
1512
1613 // Data returns the base data directory.
1714 func Data() string {
18 return data()
15 return dataDir
1916 }
2017
2118 func firstGetenv(def string, evs ...string) string {
2421 return v
2522 }
2623 }
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
3032 }
3133 return filepath.Join(home, def)
3234 }
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 }
22
33 package basedir
44
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")
44
55 package basedir
66
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")
22
33 package basedir
44
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
-42
cmd/fdroidcl/defaults.go less more
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
-68
cmd/fdroidcl/devices.go less more
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
-61
cmd/fdroidcl/download.go less more
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
-171
cmd/fdroidcl/endtoend_test.go less more
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
-92
cmd/fdroidcl/install.go less more
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
-48
cmd/fdroidcl/list.go less more
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
-213
cmd/fdroidcl/main.go less more
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
-259
cmd/fdroidcl/search.go less more
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
-153
cmd/fdroidcl/show.go less more
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
-45
cmd/fdroidcl/uninstall.go less more
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
-201
cmd/fdroidcl/update.go less more
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
-46
fieldtypes.go less more
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
-306
index.go less more
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
-157
index_test.go less more
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
-36
jar.go less more
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 list categories
5 stdout 'Development'
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 '&amp'
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'
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 }