├── appland-api.js ├── test └── download.test.js ├── .gitignore ├── .jshintrc ├── config.js ├── form-encode.js ├── model ├── FirstTimeStartup.js └── LoginRequest.js ├── collect-response.js ├── appland-urls.js ├── README.md ├── first-time-startup.js ├── login-request.js ├── appland-request.js ├── package.json ├── download-app.js ├── bin └── download └── form-post.js /appland-api.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/download.test.js: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /data 3 | *.apk 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true 4 | } 5 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('rc')('appland', { 2 | url: 'https://appdoor2.appland.se', 3 | timeout: 30000 4 | }); 5 | -------------------------------------------------------------------------------- /form-encode.js: -------------------------------------------------------------------------------- 1 | module.exports = function encode(obj) { 2 | var data; 3 | if (typeof obj === 'string') 4 | data = obj; 5 | if (typeof obj === 'object') 6 | data = JSON.stringify(obj); 7 | 8 | return encodeURIComponent(encodeURIComponent(data)); 9 | }; 10 | -------------------------------------------------------------------------------- /model/FirstTimeStartup.js: -------------------------------------------------------------------------------- 1 | module.exports = function (opts) { 2 | opts = opts || {}; 3 | return { 4 | "pv": opts.pv || 700, 5 | "store": opts.store || "APPLAND_SE", 6 | "language": opts.language || "en", 7 | "operator": opts.operator || "310260" 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /collect-response.js: -------------------------------------------------------------------------------- 1 | const bl = require('bl'); 2 | 3 | module.exports = function collectResponse(conn) { 4 | return new Promise(function (resolve, reject) { 5 | conn.pipe(bl(function (err, res) { 6 | if (err) { return reject(err); } 7 | return resolve(res); 8 | })); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /appland-urls.js: -------------------------------------------------------------------------------- 1 | const rc = require('./config'); 2 | const debug = require('debug')('appland-urls'); 3 | 4 | debug(rc); 5 | 6 | const base = rc.url || 'https://appdoor2.appland.se'; 7 | 8 | module.exports = { 9 | LoginReq: base+'/api/com', 10 | FirstTimeStartupReq: base+'/api/com', 11 | Download: base+'/dl' 12 | }; 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SYNOPSIS 2 | 3 | Download APKs from [appland](http://www.appland.se/); 4 | 5 | # INSTALLATION 6 | 7 | ```sh 8 | $ npm install -g appland 9 | ``` 10 | 11 | # USAGE 12 | 13 | ```sh 14 | $ appland-download -o foo.apk http://www.appland.se/sv/app/25823 15 | or 16 | $ appland-download http://www.appland.se/sv/app/25823 > foo.apk 17 | ``` 18 | 19 | -------------------------------------------------------------------------------- /first-time-startup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const urls = require('./appland-urls'); 3 | const co = require('co'); 4 | const applandRequest = require('./appland-request'); 5 | 6 | module.exports = co.wrap(function* (opts) { 7 | return yield applandRequest(urls.FirstTimeStartupReq, { 8 | FirstTimeStartupReq: require('./model/FirstTimeStartup')(opts) 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /login-request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const urls = require('./appland-urls'); 3 | const debug = require('debug')('login-request'); 4 | const co = require('co'); 5 | const applandRequest = require('./appland-request'); 6 | 7 | module.exports = co.wrap(function* (opts) { 8 | return yield applandRequest(urls.LoginReq, { 9 | LoginReq: require('./model/LoginRequest')(opts) 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /appland-request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const postForm = require('./form-post'); 3 | const encode = require('./form-encode'); 4 | const debug = require('debug')('appland-request'); 5 | const co = require('co'); 6 | const collectResponse = require('./collect-response'); 7 | 8 | module.exports = co.wrap(function* (url, data) { 9 | debug(url, data); 10 | const conn = yield postForm({ 11 | url: url, 12 | body: 'REQ='+encode(data) 13 | }); 14 | 15 | const ret = yield collectResponse(conn); 16 | debug(ret.toString()); 17 | return ret; 18 | }); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appland", 3 | "version": "0.0.4", 4 | "description": "appland store downloader", 5 | "main": "appland-api.js", 6 | "bin": { 7 | "appland-download": "bin/download" 8 | }, 9 | "dependencies": { 10 | "bl": "^1.0.0", 11 | "bluebird": "^2.9.34", 12 | "co": "^4.6.0", 13 | "debug": "^2.2.0", 14 | "hyperquest": "^1.2.0", 15 | "rc": "^1.1.0", 16 | "xtend": "^4.0.0" 17 | }, 18 | "devDependencies": { 19 | "tape": "^4.0.1" 20 | }, 21 | "scripts": { 22 | "test": "tape ./test/*.test.js" 23 | }, 24 | "keywords": [ 25 | "appland", 26 | "apk" 27 | ], 28 | "author": "dweinstein" 29 | } 30 | -------------------------------------------------------------------------------- /download-app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const assert = require('assert'); 3 | const formPost = require('./form-post'); 4 | const urls = require('./appland-urls'); 5 | const debug = require('debug')('download-app'); 6 | const stringify = require('querystring').stringify; 7 | 8 | module.exports = function download(sessionId, appId) { 9 | assert(typeof sessionId !== 'undefined', 'require sessionId'); 10 | assert(typeof appId !== 'undefined', 'require appId'); 11 | 12 | const data = { 13 | pv: 700, 14 | appId: appId, 15 | sessionId: sessionId, 16 | blocking: 0, 17 | store: 'APPLAND_SE', 18 | t: 'OA/LSTARTPAGE_ODPA/P2' 19 | }; 20 | 21 | debug(data); 22 | 23 | return formPost({ 24 | url: urls.Download, 25 | body: stringify(data), 26 | headers: { 27 | 'user_agent': 'Android' // consistent with appland client 28 | } 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /bin/download: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | const loginRequest = require('../login-request'); 4 | const firstTimeStartup = require('../first-time-startup'); 5 | const downloadApp = require('../download-app'); 6 | const co = require('co'); 7 | const fs = require('fs'); 8 | const rc = require('../config'); 9 | const debug = require('debug')('appland:bin'); 10 | const assert = require('assert'); 11 | const urlParse = require('url').parse; 12 | 13 | function parseAppId(str) { 14 | const match = /^https?:.*\/app\/(\d+)/.exec(str); 15 | if (match) { 16 | return match[1]; 17 | } 18 | return str; 19 | } 20 | 21 | co(function*() { 22 | const appId = parseAppId(rc.id || rc._[0]); 23 | assert(appId, 'must supply app id'); 24 | 25 | const startup = yield firstTimeStartup(); 26 | const deviceToken = JSON.parse(startup).FirstTimeStartupResp.deviceToken; 27 | const loginResp = yield loginRequest({deviceToken: deviceToken}); 28 | const sessionId = JSON.parse(loginResp).LoginResp.sessionId; 29 | const stream = yield downloadApp(sessionId, appId); 30 | 31 | const out = rc.o ? fs.createWriteStream(rc.o) : process.stdout; 32 | stream.pipe(out); 33 | stream.on('end', function () { 34 | debug('stream ended'); 35 | }); 36 | }).catch(function (err) { 37 | console.error(err.stack); 38 | process.exit(1); 39 | }); 40 | -------------------------------------------------------------------------------- /form-post.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const q = require('hyperquest'); 3 | const fs = require('fs'); 4 | const Promise = require('bluebird'); 5 | const rc = require('./config'); 6 | const assert = require('assert'); 7 | const debug = require('debug')('form-post'); 8 | const xtend = require('xtend'); 9 | 10 | function handleRedirect(url, opts) { 11 | return new Promise(function (resolve, reject) { 12 | debug('redirect to %s', url); 13 | const conn = q(url, opts, function (err, res) { 14 | if (err) { 15 | return reject(err); 16 | } 17 | if (res.statusCode === 302) { 18 | return handleRedirect(res.headers.location || res.headers.Location, opts); 19 | } 20 | if (res.statusCode === 200) { 21 | return resolve(conn); 22 | } else { 23 | return reject(res.statusCode); 24 | } 25 | }); 26 | }).timeout(rc.timeout); 27 | } 28 | 29 | module.exports = function postFormForResp(opts) { 30 | return new Promise(function (resolve, reject) { 31 | assert(typeof opts.url !== 'undefined', 'opts.url required'); 32 | 33 | const url = opts.url; 34 | const req = { 35 | method: 'POST', 36 | headers: { 37 | 'Content-Type': 'application/x-www-form-urlencoded', 38 | } 39 | }; 40 | 41 | if (opts.body) { 42 | req.headers['Content-Length'] = opts.body.length; 43 | } 44 | 45 | if (opts.headers) { 46 | req.headers = xtend(req.headers, opts.headers); 47 | } 48 | 49 | debug(req); 50 | const conn = q(url, req, function (err, res) { 51 | if (err) { 52 | return reject(err); 53 | } 54 | if (res.statusCode === 200) { 55 | return resolve(conn); 56 | } 57 | else if (res.statusCode === 302) { 58 | return resolve( 59 | handleRedirect(res.headers.location || res.headers.Location, opts.headers) 60 | ); 61 | } else { 62 | return reject(res.statusCode); 63 | } 64 | }); 65 | 66 | if (opts.body) { 67 | debug(opts.body); 68 | conn.write(opts.body); 69 | conn.end(); 70 | } 71 | 72 | }).timeout(rc.timeout); 73 | }; 74 | 75 | -------------------------------------------------------------------------------- /model/LoginRequest.js: -------------------------------------------------------------------------------- 1 | module.exports = function (opts) { 2 | opts = opts || {}; 3 | return { 4 | "deviceToken": opts.deviceToken || "kiCS7Kvht1DVFBKwE2kEjLrEZF5xaIQa", 5 | "phoneDetails": opts.phoneDetails || { 6 | "APPVersionCode": opts.APPVersionCode || 1246, 7 | "versionName": opts.versionName || "3.1.0", 8 | "apiLevel": opts.apiLevel || 19, 9 | "operator": opts.operator || "310260", 10 | "phoneModel": opts.phoneModel || "Google+Nexus+5+-+4.4.4+-+API+19+-+1080x1920", 11 | "country": opts.country || "United+States", 12 | "language": opts.language || "en", 13 | "androidId": opts.androidId || "8c01457f2ad53699", 14 | "gcmRegistrationId": opts.gcmRegistrationId || "", 15 | "imei": opts.imei || "000000000000000", 16 | "deviceFeatures": opts.deviceFeatures || { 17 | "screenSpec": { 18 | "size": 2, 19 | "density": 480 20 | }, 21 | "configuration": { 22 | "reqTouchScreen": 3, 23 | "reqKeyboardType": 2, 24 | "reqNavigation": 2, 25 | "reqInputFeatures": 3, 26 | "reqGlEsVersion": 131072 27 | }, 28 | "features": [ 29 | "android.hardware.wifi", 30 | "android.hardware.location.network", 31 | "android.hardware.location", 32 | "android.software.input_methods", 33 | "android.hardware.screen.landscape", 34 | "android.hardware.screen.portrait", 35 | "android.hardware.usb.accessory", 36 | "android.hardware.camera.any", 37 | "android.hardware.touchscreen.multitouch.distinct", 38 | "android.hardware.microphone", 39 | "android.software.live_wallpaper", 40 | "android.software.app_widgets", 41 | "android.hardware.telephony", 42 | "android.software.sip", 43 | "android.hardware.touchscreen.multitouch.jazzhand", 44 | "android.hardware.usb.host", 45 | "android.hardware.touchscreen.multitouch", 46 | "android.hardware.faketouch", 47 | "android.hardware.camera", 48 | "android.software.home_screen", 49 | "android.software.sip.voip", 50 | "android.hardware.location.gps", 51 | "android.hardware.telephony.gsm", 52 | "android.software.device_admin", 53 | "android.hardware.camera.front", 54 | "android.hardware.sensor.accelerometer", 55 | "android.hardware.touchscreen" 56 | ], 57 | "glEsVersion": 131072, 58 | "libraries": [ 59 | "android.test.runner", 60 | "javax.obex", 61 | "com.android.future.usb.accessory", 62 | "com.android.location.provider", 63 | "com.android.media.remotedisplay" 64 | ], 65 | "sdkVersion": 19, 66 | "cpu": "x86", 67 | "cpu2": "unknown" 68 | } 69 | }, 70 | "pv": opts.pv || 700, 71 | "store": opts.store || "APPLAND_SE", 72 | "language": opts.language || "en", 73 | "operator": opts.operator || "310260" 74 | }; 75 | }; 76 | --------------------------------------------------------------------------------