Browse Source

Added native support for tTorrent (based on old qBittorrent adapter). Fixes #202.

pull/222/merge
Eric Kok 10 years ago
parent
commit
25ec5a50aa
  1. 21
      app/src/main/java/org/transdroid/daemon/Daemon.java
  2. 450
      app/src/main/java/org/transdroid/daemon/Ttorrent/TtorrentAdapter.java
  3. 6
      app/src/main/res/values/strings.xml

21
app/src/main/java/org/transdroid/daemon/Daemon.java

@ -29,6 +29,7 @@ import org.transdroid.daemon.Rtorrent.RtorrentAdapter; @@ -29,6 +29,7 @@ import org.transdroid.daemon.Rtorrent.RtorrentAdapter;
import org.transdroid.daemon.Synology.SynologyAdapter;
import org.transdroid.daemon.Tfb4rt.Tfb4rtAdapter;
import org.transdroid.daemon.Transmission.TransmissionAdapter;
import org.transdroid.daemon.Ttorrent.TtorrentAdapter;
import org.transdroid.daemon.Utorrent.UtorrentAdapter;
import org.transdroid.daemon.Vuze.VuzeAdapter;
@ -95,6 +96,11 @@ public enum Daemon { @@ -95,6 +96,11 @@ public enum Daemon {
return new Tfb4rtAdapter(settings);
}
},
tTorrent {
public IDaemonAdapter createAdapter(DaemonSettings settings) {
return new TtorrentAdapter(settings);
}
},
Synology {
public IDaemonAdapter createAdapter(DaemonSettings settings) {
return new SynologyAdapter(settings);
@ -157,6 +163,8 @@ public enum Daemon { @@ -157,6 +163,8 @@ public enum Daemon {
return "daemon_synology";
case Tfb4rt:
return "daemon_tfb4rt";
case tTorrent:
return "daemon_ttorrent";
case Transmission:
return "daemon_transmission";
case uTorrent:
@ -216,6 +224,9 @@ public enum Daemon { @@ -216,6 +224,9 @@ public enum Daemon {
if (daemonCode.equals("daemon_tfb4rt")) {
return Tfb4rt;
}
if (daemonCode.equals("daemon_ttorrent")) {
return tTorrent;
}
if (daemonCode.equals("daemon_transmission")) {
return Transmission;
}
@ -265,6 +276,8 @@ public enum Daemon { @@ -265,6 +276,8 @@ public enum Daemon {
return 6884;
case Aria2:
return 6800;
case tTorrent:
return 1080;
}
return 8080;
}
@ -278,7 +291,7 @@ public enum Daemon { @@ -278,7 +291,7 @@ public enum Daemon {
}
public static boolean supportsFileListing(Daemon type) {
return type == Synology || type == Transmission || type == uTorrent || type == BitTorrent || type == KTorrent || type == Deluge || type == rTorrent || type == Vuze || type == DLinkRouterBT || type == Bitflu || type == qBittorrent || type == BuffaloNas || type == BitComet || type == Aria2 || type == Dummy;
return type == Synology || type == Transmission || type == uTorrent || type == BitTorrent || type == KTorrent || type == Deluge || type == rTorrent || type == Vuze || type == DLinkRouterBT || type == Bitflu || type == qBittorrent || type == BuffaloNas || type == BitComet || type == Aria2 || type == tTorrent || type == Dummy;
}
public static boolean supportsFineDetails(Daemon type) {
@ -315,15 +328,15 @@ public enum Daemon { @@ -315,15 +328,15 @@ public enum Daemon {
}
public static boolean supportsAddByMagnetUrl(Daemon type) {
return type == uTorrent || type == BitTorrent || type == Transmission || type == Synology || type == Deluge || type == Bitflu || type == KTorrent || type == rTorrent || type == qBittorrent || type == BitComet || type == Aria2 || type == Dummy;
return type == uTorrent || type == BitTorrent || type == Transmission || type == Synology || type == Deluge || type == Bitflu || type == KTorrent || type == rTorrent || type == qBittorrent || type == BitComet || type == Aria2 || type == tTorrent || type == Dummy;
}
public static boolean supportsRemoveWithData(Daemon type) {
return type == uTorrent || type == Vuze || type == Transmission || type == Deluge || type == BitTorrent || type == Tfb4rt || type == DLinkRouterBT || type == Bitflu || type == qBittorrent || type == BuffaloNas || type == BitComet || type == rTorrent || type == Aria2 || type == Dummy;
return type == uTorrent || type == Vuze || type == Transmission || type == Deluge || type == BitTorrent || type == Tfb4rt || type == DLinkRouterBT || type == Bitflu || type == qBittorrent || type == BuffaloNas || type == BitComet || type == rTorrent || type == Aria2 || type == tTorrent || type == Dummy;
}
public static boolean supportsFilePrioritySetting(Daemon type) {
return type == BitTorrent || type == uTorrent || type == Transmission || type == KTorrent || type == rTorrent || type == Vuze || type == Deluge || type == qBittorrent || type == Dummy;
return type == BitTorrent || type == uTorrent || type == Transmission || type == KTorrent || type == rTorrent || type == Vuze || type == Deluge || type == qBittorrent || type == tTorrent || type == Dummy;
}
public static boolean supportsDateAdded(Daemon type) {

450
app/src/main/java/org/transdroid/daemon/Ttorrent/TtorrentAdapter.java

@ -0,0 +1,450 @@ @@ -0,0 +1,450 @@
/*
* This file is part of Transdroid <http://www.transdroid.org>
*
* Transdroid is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Transdroid is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Transdroid. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.transdroid.daemon.Ttorrent;
import com.android.internalcopy.http.multipart.FilePart;
import com.android.internalcopy.http.multipart.MultipartEntity;
import com.android.internalcopy.http.multipart.Part;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.cookie.Cookie;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.HTTP;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.transdroid.core.gui.log.Log;
import org.transdroid.daemon.Daemon;
import org.transdroid.daemon.DaemonException;
import org.transdroid.daemon.DaemonException.ExceptionType;
import org.transdroid.daemon.DaemonSettings;
import org.transdroid.daemon.IDaemonAdapter;
import org.transdroid.daemon.Priority;
import org.transdroid.daemon.Torrent;
import org.transdroid.daemon.TorrentDetails;
import org.transdroid.daemon.TorrentFile;
import org.transdroid.daemon.TorrentStatus;
import org.transdroid.daemon.task.AddByFileTask;
import org.transdroid.daemon.task.AddByMagnetUrlTask;
import org.transdroid.daemon.task.AddByUrlTask;
import org.transdroid.daemon.task.DaemonTask;
import org.transdroid.daemon.task.DaemonTaskFailureResult;
import org.transdroid.daemon.task.DaemonTaskResult;
import org.transdroid.daemon.task.DaemonTaskSuccessResult;
import org.transdroid.daemon.task.GetFileListTask;
import org.transdroid.daemon.task.GetFileListTaskSuccessResult;
import org.transdroid.daemon.task.GetTorrentDetailsTask;
import org.transdroid.daemon.task.GetTorrentDetailsTaskSuccessResult;
import org.transdroid.daemon.task.RemoveTask;
import org.transdroid.daemon.task.RetrieveTask;
import org.transdroid.daemon.task.RetrieveTaskSuccessResult;
import org.transdroid.daemon.task.SetFilePriorityTask;
import org.transdroid.daemon.task.SetTransferRatesTask;
import org.transdroid.daemon.util.HttpHelper;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* The daemon adapter for the tTorrent Android torrent client.
* @author erickok
*/
public class TtorrentAdapter implements IDaemonAdapter {
private static final String LOG_NAME = "tTorrent daemon";
private DaemonSettings settings;
private DefaultHttpClient httpclient;
public TtorrentAdapter(DaemonSettings settings) {
this.settings = settings;
}
@Override
public DaemonTaskResult executeTask(Log log, DaemonTask task) {
try {
switch (task.getMethod()) {
case Retrieve:
// Request all torrents from server
JSONArray result = new JSONArray(makeRequest(log, "/json/events"));
return new RetrieveTaskSuccessResult((RetrieveTask) task, parseJsonTorrents(result), null);
case GetTorrentDetails:
// Request tracker and error details for a specific teacher
String mhash = task.getTargetTorrent().getUniqueID();
JSONArray messages =
new JSONArray(makeRequest(log, "/json/propertiesTrackers/" + mhash));
return new GetTorrentDetailsTaskSuccessResult((GetTorrentDetailsTask) task, parseJsonTorrentDetails(messages));
case GetFileList:
// Request files listing for a specific torrent
String fhash = task.getTargetTorrent().getUniqueID();
JSONArray files =
new JSONArray(makeRequest(log, "/json/propertiesFiles/" + fhash));
return new GetFileListTaskSuccessResult((GetFileListTask) task, parseJsonFiles(files));
case AddByFile:
// Upload a local .torrent file
String ufile = ((AddByFileTask) task).getFile();
makeUploadRequest("/command/upload", ufile, log);
return new DaemonTaskSuccessResult(task);
case AddByUrl:
// Request to add a torrent by URL
String url = ((AddByUrlTask) task).getUrl();
makeRequest(log, "/command/download", new BasicNameValuePair("urls", url));
return new DaemonTaskSuccessResult(task);
case AddByMagnetUrl:
// Request to add a magnet link by URL
String magnet = ((AddByMagnetUrlTask) task).getUrl();
makeRequest(log, "/command/download", new BasicNameValuePair("urls", magnet));
return new DaemonTaskSuccessResult(task);
case Remove:
// Remove a torrent
RemoveTask removeTask = (RemoveTask) task;
makeRequest(log, (removeTask.includingData() ? "/command/deletePerm" : "/command/delete"),
new BasicNameValuePair("hashes", removeTask.getTargetTorrent().getUniqueID()));
return new DaemonTaskSuccessResult(task);
case Pause:
// Pause a torrent
makeRequest(log, "/command/pause", new BasicNameValuePair("hash", task.getTargetTorrent().getUniqueID()));
return new DaemonTaskSuccessResult(task);
case PauseAll:
// Resume all torrents
makeRequest(log, "/command/pauseall");
return new DaemonTaskSuccessResult(task);
case Resume:
// Resume a torrent
makeRequest(log, "/command/resume", new BasicNameValuePair("hash", task.getTargetTorrent().getUniqueID()));
return new DaemonTaskSuccessResult(task);
case ResumeAll:
// Resume all torrents
makeRequest(log, "/command/resumeall");
return new DaemonTaskSuccessResult(task);
case SetFilePriorities:
// Update the priorities to a set of files
SetFilePriorityTask setPrio = (SetFilePriorityTask) task;
String newPrio = "0";
if (setPrio.getNewPriority() == Priority.Low) {
newPrio = "1";
} else if (setPrio.getNewPriority() == Priority.Normal) {
newPrio = "2";
} else if (setPrio.getNewPriority() == Priority.High) {
newPrio = "7";
}
// We have to make a separate request per file, it seems
for (TorrentFile file : setPrio.getForFiles()) {
makeRequest(log, "/command/setFilePrio", new BasicNameValuePair("hash", task.getTargetTorrent().getUniqueID()),
new BasicNameValuePair("id", file.getKey()), new BasicNameValuePair("priority", newPrio));
}
return new DaemonTaskSuccessResult(task);
default:
return new DaemonTaskFailureResult(task,
new DaemonException(ExceptionType.MethodUnsupported, task.getMethod() + " is not supported by " + getType()));
}
} catch (JSONException e) {
return new DaemonTaskFailureResult(task, new DaemonException(ExceptionType.ParsingFailed, e.toString()));
} catch (DaemonException e) {
return new DaemonTaskFailureResult(task, e);
}
}
private String makeRequest(Log log, String path, NameValuePair... params) throws DaemonException {
try {
// Setup request using POST
HttpPost httppost = new HttpPost(buildWebUIUrl(path));
List<NameValuePair> nvps = new ArrayList<>();
Collections.addAll(nvps, params);
httppost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8));
return makeWebRequest(httppost, log);
} catch (UnsupportedEncodingException e) {
throw new DaemonException(ExceptionType.ConnectionError, e.toString());
}
}
private String makeUploadRequest(String path, String file, Log log) throws DaemonException {
try {
// Setup request using POST
HttpPost httppost = new HttpPost(buildWebUIUrl(path));
File upload = new File(URI.create(file));
Part[] parts = {new FilePart("torrentfile", upload)};
httppost.setEntity(new MultipartEntity(parts, httppost.getParams()));
return makeWebRequest(httppost, log);
} catch (FileNotFoundException e) {
throw new DaemonException(ExceptionType.FileAccessError, e.toString());
}
}
private String makeWebRequest(HttpPost httppost, Log log) throws DaemonException {
try {
// Initialise the HTTP client
if (httpclient == null) {
initialise();
}
// Execute
HttpResponse response = httpclient.execute(httppost);
HttpEntity entity = response.getEntity();
if (entity != null) {
// Read JSON response
java.io.InputStream instream = entity.getContent();
String result = HttpHelper.convertStreamToString(instream);
instream.close();
// Return raw result
return result;
}
log.d(LOG_NAME, "Error: No entity in HTTP response");
throw new DaemonException(ExceptionType.UnexpectedResponse, "No HTTP entity object in response.");
} catch (Exception e) {
log.d(LOG_NAME, "Error: " + e.toString());
throw new DaemonException(ExceptionType.ConnectionError, e.toString());
}
}
/**
* Instantiates an HTTP client with proper credentials that can be used for all tTorrent requests.
* @throws DaemonException On conflicting or missing settings
*/
private void initialise() throws DaemonException {
httpclient = HttpHelper.createStandardHttpClient(settings, true);
}
/**
* Build the URL of the web UI request from the user settings
* @return The URL to request
*/
private String buildWebUIUrl(String path) {
return (settings.getSsl() ? "https://" : "http://") + settings.getAddress() + ":" + settings.getPort() + path;
}
private TorrentDetails parseJsonTorrentDetails(JSONArray messages) throws JSONException {
ArrayList<String> trackers = new ArrayList<>();
ArrayList<String> errors = new ArrayList<>();
// Parse response
if (messages.length() > 0) {
for (int i = 0; i < messages.length(); i++) {
JSONObject tor = messages.getJSONObject(i);
trackers.add(tor.getString("url"));
String msg = tor.getString("msg");
if (msg != null && !msg.equals(""))
errors.add(msg);
}
}
// Return the list
return new TorrentDetails(trackers, errors);
}
private ArrayList<Torrent> parseJsonTorrents(JSONArray response) throws JSONException {
// Parse response
ArrayList<Torrent> torrents = new ArrayList<>();
for (int i = 0; i < response.length(); i++) {
JSONObject tor = response.getJSONObject(i);
double progress = tor.getDouble("progress");
int leechers[] = parsePeers(tor.getString("num_leechs"));
int seeders[] = parsePeers(tor.getString("num_seeds"));
long size = parseSize(tor.getString("size"));
double ratio = parseRatio(tor.getString("ratio"));
int dlspeed = (int) parseSize(tor.getString("dlspeed"));
int upspeed = (int) parseSize(tor.getString("upspeed"));
long eta = -1L;
if (dlspeed > 0)
eta = (long) (size - (size * progress)) / dlspeed;
// @formatter:off
torrents.add(new Torrent(
(long) i,
tor.getString("hash"),
tor.getString("name"),
parseStatus(tor.getString("state")),
null,
dlspeed,
upspeed,
seeders[0],
seeders[1],
leechers[0],
leechers[1],
(int) eta,
(long) (size * progress),
(long) (size * ratio),
size,
(float) progress,
0f,
null,
null,
null,
null,
settings.getType()));
// @formatter:on
}
// Return the list
return torrents;
}
private double parseRatio(String string) {
// Ratio is given in "1.5" string format
try {
return Double.parseDouble(string);
} catch (Exception e) {
return 0D;
}
}
private long parseSize(String string) {
if (string.equals("Unknown"))
return -1;
// Sizes are given in "1562690683 B"-like string format
String[] parts = string.split(" ");
try {
return Long.parseLong(parts[0]);
} catch (Exception e) {
return -1L;
}
}
private int[] parsePeers(String seeds) {
// Peers (seeders or leechers) are defined in a string like "num_seeds":"66 (27)" but we are also compatible with the old
// "num_seeds":"66 (27)" format
String[] parts = seeds.split(" ");
if (parts.length > 1) {
return new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[1].substring(1, parts[1].length() - 1))};
}
return new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[0])};
}
private TorrentStatus parseStatus(String state) {
// Status is given as a descriptive string
if (state.equals("downloading")) {
return TorrentStatus.Downloading;
} else if (state.equals("uploading")) {
return TorrentStatus.Seeding;
} else if (state.equals("pausedDL")) {
return TorrentStatus.Paused;
} else if (state.equals("pausedUL")) {
return TorrentStatus.Paused;
} else if (state.equals("stalledUP")) {
return TorrentStatus.Seeding;
} else if (state.equals("stalledDL")) {
return TorrentStatus.Downloading;
} else if (state.equals("checkingUP")) {
return TorrentStatus.Checking;
} else if (state.equals("checkingDL")) {
return TorrentStatus.Checking;
} else if (state.equals("queuedDL")) {
return TorrentStatus.Queued;
} else if (state.equals("queuedUL")) {
return TorrentStatus.Queued;
}
return TorrentStatus.Unknown;
}
private ArrayList<TorrentFile> parseJsonFiles(JSONArray response) throws JSONException {
// Parse response
ArrayList<TorrentFile> torrentfiles = new ArrayList<>();
for (int i = 0; i < response.length(); i++) {
JSONObject file = response.getJSONObject(i);
long size = parseSize(file.getString("size"));
torrentfiles.add(new TorrentFile("" + i, file.getString("name"), null, null, size, (long) (size * file.getDouble("progress")),
parsePriority(file.getInt("priority"))));
}
// Return the list
return torrentfiles;
}
private Priority parsePriority(int priority) {
// Priority is an integer
// Actually 1 = Normal, 2 = High, 7 = Maximum, but adjust this to Transdroid values
if (priority == 0) {
return Priority.Off;
} else if (priority == 1) {
return Priority.Low;
} else if (priority == 2) {
return Priority.Normal;
}
return Priority.High;
}
@Override
public Daemon getType() {
return settings.getType();
}
@Override
public DaemonSettings getSettings() {
return this.settings;
}
}

6
app/src/main/res/values/strings.xml

@ -370,12 +370,12 @@ @@ -370,12 +370,12 @@
<item>Buffalo NAS -1.31</item>
<item>Deluge 1.2+</item>
<item>DLink Router BT</item>
<item>Dummy</item>
<item>Ktorrent</item>
<item>qBittorrent</item>
<item>rTorrent</item>
<item>Synology</item>
<item>Torrentflux-b4rt</item>
<item>tTorrent</item>
<item>Transmission</item>
<item>µTorrent</item>
<item>Vuze</item>
@ -388,12 +388,12 @@ @@ -388,12 +388,12 @@
<item>daemon_buffalonas</item>
<item>daemon_deluge</item>
<item>daemon_dlinkrouterbt</item>
<item>daemon_dummy</item>
<item>daemon_ktorrent</item>
<item>daemon_qbittorrent</item>
<item>daemon_rtorrent</item>
<item>daemon_synology</item>
<item>daemon_tfb4rt</item>
<item>daemon_ttorrent</item>
<item>daemon_transmission</item>
<item>daemon_utorrent</item>
<item>daemon_vuze</item>
@ -462,7 +462,7 @@ @@ -462,7 +462,7 @@
<string name="system_developer" translatable="false">\u00A9 Eric Kok, 2312 development</string>
<string name="system_license" translatable="false">Published under GNU General Public License v3</string>
<string name="system_librarieslabel">Some code/libraries are used in the project:</string>
<string name="system_libraries" translatable="false">AndroidAnnotations\n \u00A0 http://androidannotations.org/\n \u00A0 Pierre-Yves Ricau (eBusinessInformations) et al. \n \u00A0 Apache License, Version 2.0\nActionBar-PullToRefresh\n \u00A0 https://github.com/chrisbanes/ActionBar-PullToRefresh\n \u00A0 Chris Banes \n \u00A0 Apache License, Version 2.0\nCrouton\n \u00A0 https://github.com/keyboardsurfer/Crouton\n \u00A0 Code: Benjamin Weiss (Neofonie Mobile Gmbh) et al. \n \u00A0 Idea: Cyril Mottier \n \u00A0 Apache License, Version 2.0\nBase16Encoder\n \u00A0 http://openjpa.apache.org/\n \u00A0 Marc Prud\'hommeaux \n \u00A0 Apache OpenJPA\n MultipartEntity \n \u00A0 Apache Software Foundation \n \u00A0 Apache License, Version 2.0\nRssParser (learning-android)\n \u00A0 http://github.com/digitalspaghetti/learning-android\n \u00A0 Tane Piper \n \u00A0 Public Domain\nBase64\n \u00A0 http://iharder.net/base64\n \u00A0 Robert Harder \n \u00A0 Public Domain\naXMLRPC\n \u00A0 https://github.com/timroes/aXMLRPC\n \u00A0 Tim Roes \n \u00A0 MIT License\nandroid-ColorPickerPreference\n \u00A0 https://github.com/attenzione/android-ColorPickerPreference\n \u00A0 Daniel Nilsson and Sergey Margaritov \n \u00A0 Apache License, Version 2.0\nCheckableRelativeLayout\n \u00A0 http://www.marvinlabs.com/2010/10/custom-listview-ability-check-items/\n \u00A0 Cédric Caron (MarvinLabs)\n \u00A0 Public Domain\nFunnel icon\n \u00A0 http://thenounproject.com/noun/funnel/#icon-No5608\n \u00A0 Naomi Atkinson from The Noun Project\n \u00A0 Creative Commons Attribution 3.0</string>
<string name="system_libraries" translatable="false">AndroidAnnotations\n \u00A0 http://androidannotations.org/\n \u00A0 Pierre-Yves Ricau (eBusinessInformations) et al. \n \u00A0 Apache License, Version 2.0\nMaterial Dialogs\n \u00A0 https://github.com/afollestad/material-dialogs\n \u00A0 Aidan Follestad et al. \n \u00A0 MIT License\nSnackbar\n \u00A0 https://github.com/nispok/snackbar\n \u00A0 William Mora et al. \n \u00A0 MIT License\nFloatingActionButton\n \u00A0 https://github.com/futuresimple/android-floating-action-button\n \u00A0 Jerzy Chałupski et al. \n \u00A0 Apache License, Version 2.0\nBase16Encoder\n \u00A0 http://openjpa.apache.org/\n \u00A0 Marc Prud\'hommeaux \n \u00A0 Apache OpenJPA\n MultipartEntity \n \u00A0 Apache Software Foundation \n \u00A0 Apache License, Version 2.0\nRssParser (learning-android)\n \u00A0 http://github.com/digitalspaghetti/learning-android\n \u00A0 Tane Piper \n \u00A0 Public Domain\nBase64\n \u00A0 http://iharder.net/base64\n \u00A0 Robert Harder \n \u00A0 Public Domain\naXMLRPC\n \u00A0 https://github.com/timroes/aXMLRPC\n \u00A0 Tim Roes \n \u00A0 MIT License\nandroid-ColorPickerPreference\n \u00A0 https://github.com/attenzione/android-ColorPickerPreference\n \u00A0 Daniel Nilsson and Sergey Margaritov \n \u00A0 Apache License, Version 2.0\nFunnel icon\n \u00A0 http://thenounproject.com/noun/funnel/#icon-No5608\n \u00A0 Naomi Atkinson from The Noun Project\n \u00A0 Creative Commons Attribution 3.0</string>
<string name="system_description">Manage your torrents from your Android device</string>
</resources>

Loading…
Cancel
Save