Created
June 19, 2012 18:46
-
-
Save gitbuh/2955835 to your computer and use it in GitHub Desktop.
Quick Patch v0.0.3 pre-alpha
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(function(){ | |
// sl4a http://code.google.com/p/android-scripting/wiki/ApiReference | |
// rhino https://developer.mozilla.org/en/Rhino_Shell#Predefined_Properties | |
// java.io http://docs.oracle.com/javase/6/docs/api/java/io/package-summary.html | |
var GLOBAL = (function () { return this || (0, eval)('this'); }()); | |
// EXPORTS | |
GLOBAL.main = main; | |
var app = { | |
title: "Quick Patch", | |
version: "0.0.4 pre-alpha", | |
configFile: "/mnt/sdcard/quickpatch.json" | |
}; | |
var settings = { | |
storage: "", | |
patches: {}, | |
gists: {} | |
}; | |
/** | |
Custom input handlers. | |
@see inputHandler.sz, inputHandler.byte | |
@namespace | |
*/ | |
var inputHandler = {}; | |
var droid; | |
var mounts = {}; | |
var mountCount = 0; | |
// | |
// UTIL | |
// | |
/** | |
Make toast | |
*/ | |
function toast(x) { | |
return droid.makeToast(x); | |
} | |
/** | |
Simple dialog | |
*/ | |
function dlg(title, body, yes, no) { | |
droid.dialogCreateAlert(title, body); | |
if (yes) droid.dialogSetPositiveButtonText(yes); | |
if (no) droid.dialogSetNegativeButtonText(no); | |
droid.dialogShow(); | |
var response = droid.dialogGetResponse(); | |
droid.dialogDismiss(); | |
return response; | |
} | |
/** | |
Do stuff as root | |
*/ | |
function sudo() { | |
var cmd = [].join.call(arguments, "\n"); | |
var tmp = settings.storage + '/quickpatch-cmd'; | |
var writer = new java.io.FileWriter(tmp); | |
writer.write(cmd); | |
writer.close(); | |
runCommand("su", "-c", ". " + tmp); | |
} | |
/** | |
Assert | |
*/ | |
function assert(val, comment) { | |
print("Assert: " + comment); | |
if (val !== true) { | |
throw("Assert failed!"); | |
} | |
print("(OK)"); | |
} | |
/** | |
Convert value to number. | |
Used for byte offset, initial patch data, simple options, etc. | |
@param {string|number|Array} val | |
Strings are treated as hexadecimal numbers. Arrays are processed recursively. | |
@return {number|Array.<number>} | |
*/ | |
function readNum(val) { | |
var i, result = []; | |
// number, read as decimal | |
if (val === +val) { | |
return Math.abs(val); | |
} | |
// string, read as hexadecimal | |
if (val === '' + val) { | |
return parseInt(val, 16); | |
} | |
// array, read recursively | |
if (val.length) { | |
for (i = 0; i < val.length; i++) { | |
result.push(readNum(val[i])); | |
} | |
return result; | |
} | |
throw "Bad numeric value: " + val; | |
} | |
// | |
// SETTINGS | |
// | |
/** | |
Save settings. | |
Save settings (including installed patches) to a JSON file. | |
*/ | |
function saveSettings() { | |
var writer; | |
try { | |
writer = new java.io.FileWriter(app.configFile); | |
writer.write(JSON.stringify(settings)); | |
writer.close(); | |
print("Saved settings."); | |
} | |
catch (e) { | |
dlg("Error", '' + e, "Damn."); | |
} | |
} | |
/** | |
Load saved settings. | |
*/ | |
function loadSettings() { | |
var response; | |
if (!new java.io.File(app.configFile).canRead()) { | |
print("No settings found..."); | |
while (!settings.storage) { | |
settings.storage = droid.dialogGetInput( | |
app.title + ' - ' + "Storage location", | |
"Where should we store temporary files?", | |
"/mnt/sdcard" | |
); | |
} | |
saveSettings(); | |
return; | |
} | |
try { | |
settings = JSON.parse(readFile(app.configFile)); | |
print("Loaded settings."); | |
} | |
catch (e) { | |
dlg("Error", '' + e, "Damn."); | |
} | |
} | |
// | |
// IO | |
// | |
/** | |
Write bytes to target file at offset. | |
@param {string} target | |
@param {number} offset | |
@param {Array.<number>} bytes | |
*/ | |
function writeBytesToFile(target, offset, bytes) { | |
// Assume the file is only writable by root, and the script is not running as root. | |
// We make a temporary file, use java.io to write the bytes to it, and then copy | |
// the bytes in that file to the target as root using dd in the shell. | |
var tmp = settings.storage + '/quickpatch-tmp', | |
raf, i; | |
// Create temp file | |
sudo('rm ' + tmp, 'touch ' + tmp, 'chmod 666 ' + tmp); | |
// Write bytes to temp file | |
raf = new java.io.RandomAccessFile(tmp, "rw"); | |
for (i = 0; i < bytes.length; i++) { | |
raf.write(bytes[i]); | |
} | |
raf.close(); | |
// Copy temp file into target at offset as root (dd) | |
sudo( | |
'busybox dd if=' + tmp + ' of=' + target | |
+ ' seek=' + offset + ' bs=1 count=' + bytes.length | |
+ ' conv=notrunc', | |
'rm ' + tmp | |
); | |
} | |
/** | |
Read bytes from target file at offset. | |
@param {string} target | |
@param {number} offset | |
@param {number} byteCount | |
@return {Array.<number>} bytes | |
*/ | |
function readBytesFromFile(target, offset, byteCount) { | |
// Like writeBytes, we use a temp file to resolve root access issues. | |
// This time we use dd to write to the temp file and java.io to read it. | |
var result = []; | |
var tmp = settings.storage + '/quickpatch-tmp'; | |
var raf; | |
var i; | |
sudo( | |
'dd if=' + target + ' of=' + tmp | |
+ ' skip=' + offset + ' bs=1 count=' + byteCount, | |
'chmod 666 ' + tmp, | |
'hexdump ' + tmp | |
); | |
raf = new java.io.RandomAccessFile(tmp, "r"); | |
for (i = byteCount; i--;) { | |
result.push(raf.read()); | |
} | |
raf.close(); | |
sudo('rm ' + tmp); | |
print(JSON.stringify(result)); | |
return result; | |
} | |
/** | |
Bind a new mount point to target's parent directory and remount it as writable. | |
This is necessary because the file being patched may be on a device mounted as read-only. | |
@param {string} target | |
@return {string} writable path to target. | |
*/ | |
function remount(target) { | |
if (mounts[target]) return mounts[target]; | |
print('Remounting.'); | |
var segs = target.split("/"); | |
var file = segs.pop(); | |
var path = segs.join("/"); | |
var mp = settings.storage + '/quickpatch-mnt-' + (++mountCount); | |
mounts[target] = mp + '/' + file; | |
sudo( | |
"while [ $? = 0 ]; do umount " + mp + "; done", | |
"rmdir " + mp, | |
"mkdir " + mp, | |
"busybox mount -o bind " + path + " " + mp, | |
"busybox mount -o remount,rw " + mp, | |
"mount | grep " + mp, | |
"ls -l " + mp | |
); | |
return mounts[target]; | |
} | |
/** | |
Patch the target with the selected option. | |
@param {Object} option | |
@param {string} target | |
*/ | |
function writeOption(patch, option, target) { | |
var chunk, byteOffset, handler; | |
target = remount(target); | |
for (byteOffset in option) { | |
chunk = option[byteOffset]; | |
handler = inputHandler[chunk.type]; | |
if (handler) { | |
print('handler: ' + handler + '\n' + JSON.stringify(chunk)); | |
chunk = handler(patch, option, byteOffset); | |
print('... ' + JSON.stringify(chunk)); | |
} | |
writeBytesToFile(target, readNum(byteOffset), readNum(chunk)); | |
} | |
// TODO: Tear down mount point. Leaving it set up for now as it makes things easier to test. | |
} | |
/** | |
Determine currently installed patch option. | |
@param {Array} patches | |
@param {string} target | |
@return {number} The index in `patches` of the currently installed patch. | |
*/ | |
function getCurrentPatchOption(patches, target) { | |
var i, byteOffset, chunk, bytesInFile; | |
checkingPatches: for (i = 0; i < patches.length; i++) { | |
for (byteOffset in patches[i]) { | |
chunk = patches[i][byteOffset]; | |
if (chunk.type) continue; // TODO: Check valid object chunks | |
bytesInFile = readBytesFromFile(target, readNum(byteOffset), chunk.length); | |
if (readNum(chunk).join(' ') != bytesInFile.join(' ')) continue checkingPatches; | |
} | |
return i; | |
} | |
// Didn't find any matches | |
return false; | |
} | |
/** | |
Load a patch from a URL. | |
Called when installing and updating patches. | |
@param {string} url | |
@return {Object} Patch data. | |
*/ | |
function loadPatch(url) { | |
var data; | |
try { | |
data = JSON.parse(readUrl(url)); | |
if (!(data && data.title)) { | |
toast('Invalid patch.'); | |
return; | |
} | |
settings.patches[data.title] = data; | |
saveSettings(); | |
return data; | |
} | |
catch (e) { | |
toast('' + e); | |
} | |
} | |
/** | |
Load patches from a github gist. | |
Called when installing and updating gists. | |
@param {string} id | |
@return {Object} Gist data. | |
*/ | |
function loadGist(id) { | |
var data, patch, hasPatches; | |
try { | |
data = JSON.parse(readUrl('https://api.github.com/gists/' + id)); | |
if (!(data && data.files)) { | |
toast('Invalid gist.'); | |
return; | |
} | |
// remove old patches | |
for (var key in settings.patches) { | |
var part = key.split('/'); | |
if (part[0] == id && !data.files[part[1]]) { | |
print("removing patch " + key); | |
delete settings.patches[key]; | |
hasPatches = true; | |
} | |
} | |
// install new patches | |
for (var key in data.files) { | |
patch = JSON.parse(data.files[key].content); | |
if (!(patch && patch.title)) { | |
continue; | |
} | |
settings.patches[id + '/' + key] = patch; | |
hasPatches = true; | |
} | |
if (hasPatches) { | |
if (!settings.gists) settings.gists = {}; | |
settings.gists[id] = data.description || id; | |
saveSettings(); | |
return data; | |
} | |
} | |
catch (e) { | |
toast('' + e); | |
} | |
} | |
// | |
// UI | |
// | |
/** | |
Show the add patch dialog. | |
*/ | |
function addPatch() { | |
var response, | |
data; | |
while (1) { | |
response = droid.dialogGetInput( | |
app.title + ' - ' + "Add Patch", | |
"Enter the patch URL:", | |
response || "http://" | |
); | |
if (!response) return; | |
if (loadPatch(response)) { | |
toast("Patch added."); | |
return; | |
} | |
} | |
} | |
function addGist() { | |
var response, | |
data; | |
while (1) { | |
response = droid.dialogGetInput( | |
app.title + ' - ' + "Add Gist", | |
"Enter the gist ID:", | |
response || "" | |
); | |
if (!response) return; | |
if (loadGist(response)) { | |
toast("Gist added."); | |
return; | |
} | |
} | |
} | |
function getPatchOptions(options, patches) { | |
for (key in settings.patches) { | |
settings.patches[key].key = key; | |
patches.push(settings.patches[key]); | |
} | |
patches.sort(function (a, b) { return a.title > b.title; }); | |
for (i = 0; i < patches.length; ++i) { | |
options.push(patches[i].title); | |
} | |
} | |
/** | |
Show the patch dialog. | |
*/ | |
function patch() { | |
// TODO: Refactor into two parts, patch list and individual patch. | |
var target, raf, key, options, patches, response, p, sel, current, i; | |
do { | |
options = []; | |
patches = []; | |
getPatchOptions(options, patches); | |
if (options.length) { | |
droid.dialogCreateAlert(app.title); | |
} else { | |
droid.dialogCreateAlert(app.title, "No patches installed."); | |
} | |
droid.dialogSetPositiveButtonText("Manage Patches"); | |
droid.dialogSetNeutralButtonText("Manage Gists"); | |
droid.dialogSetItems(options); | |
droid.dialogShow(); | |
response = droid.dialogGetResponse(); | |
droid.dialogDismiss(); | |
if (!response) break; | |
if (response.which == "positive") { | |
managePatches(); | |
response.item = 0; | |
continue; | |
} | |
if (response.which == "neutral") { | |
manageGists(); | |
response.item = 0; | |
continue; | |
} | |
if (response.item > -1) { | |
p = patches[response.item]; | |
// target = checkTarget(p); | |
target = p.target; | |
if (!target) continue; | |
options = ['Unpatched']; | |
patches = [p.initial]; | |
for (key in p.options) { | |
options.push(key); | |
patches.push(p.options[key]); | |
} | |
current = getCurrentPatchOption(patches, target); | |
if (current === false) { | |
toast("Warning! Target is in an unknown state."); | |
current = 1000; | |
} | |
if (p.description) { | |
toast(p.description); | |
droid.dialogCreateAlert(p.title, p.description); | |
} else { | |
droid.dialogCreateAlert(p.title); | |
} | |
droid.dialogSetPositiveButtonText("Apply"); | |
droid.dialogSetNegativeButtonText("Cancel"); | |
droid.dialogSetSingleChoiceItems(options, current); | |
droid.dialogShow(); | |
response = droid.dialogGetResponse(); | |
if (response.which != "positive") { | |
print("Patch aborted."); | |
response.item = 0; | |
continue; | |
} | |
sel = droid.dialogGetSelectedItems()[0]; | |
droid.dialogDismiss(); | |
print("response: " + JSON.stringify(response)); | |
print("selected: " + JSON.stringify(sel)); | |
try { | |
writeOption(p, patches[sel], target); | |
dlg("Success", "File was patched.", "Sweet."); | |
} | |
catch (e) { | |
dlg("Error", '' + e, "Damn."); | |
} | |
response.item = 0; | |
continue; | |
} | |
} while (response.item > -1); | |
} | |
/** | |
Show the manage patches dialog. | |
*/ | |
function managePatches() { | |
var title = app.title + ' - Manage Patches', | |
key, options, patches, response, sel, state, i; | |
while (1) { | |
options = []; | |
patches = []; | |
getPatchOptions(options, patches); | |
if (options.length) { | |
droid.dialogCreateAlert(title); | |
} else { | |
droid.dialogCreateAlert(title, "No patches installed."); | |
} | |
droid.dialogSetPositiveButtonText("Add Patch"); | |
droid.dialogSetNegativeButtonText("Delete"); | |
droid.dialogSetMultiChoiceItems(options); | |
droid.dialogShow(); | |
response = droid.dialogGetResponse(); | |
if (!response || response.canceled) return; | |
sel = droid.dialogGetSelectedItems(); | |
print(JSON.stringify(response)); | |
print(JSON.stringify(sel)); | |
droid.dialogDismiss(); | |
if (!response || response.which == "neutral") { | |
continue; | |
} | |
if (response.which == "positive") { | |
addPatch(); | |
continue; | |
} | |
if (response.which == "negative") { | |
if (!sel || sel.length < 1) { | |
toast("Nothing to delete."); | |
continue; | |
} | |
var desc = sel.length > 1 ? sel.length + " patches" : patches[sel[0]].title; | |
var r = dlg( | |
app.title + " - Deleting patches", | |
"Delete " + desc + "?", | |
"Delete", "Cancel"); | |
if (!r || r.which == 'negative') continue; | |
for (var i = sel.length; i--;) { | |
delete settings.patches[patches[sel[i]].key]; | |
} | |
saveSettings(); | |
} | |
} | |
} | |
/** | |
Show the manage gists dialog. | |
*/ | |
function manageGists() { | |
var title = app.title + ' - Manage Gists', | |
key, options, gists, response, sel, state; | |
while (1) { | |
options = []; | |
gists = []; | |
for (key in settings.gists) { | |
options.push(settings.gists[key]); | |
gists.push(key); | |
} | |
if (options.length) { | |
droid.dialogCreateAlert(title); | |
} else { | |
droid.dialogCreateAlert(title, "No gists installed."); | |
} | |
droid.dialogSetPositiveButtonText("Add Gist"); | |
droid.dialogSetNegativeButtonText("Delete"); | |
droid.dialogSetMultiChoiceItems(options); | |
droid.dialogShow(); | |
response = droid.dialogGetResponse(); | |
if (!response || response.canceled) return; | |
sel = droid.dialogGetSelectedItems(); | |
print(JSON.stringify(response)); | |
print(JSON.stringify(sel)); | |
droid.dialogDismiss(); | |
if (!response || response.which == "neutral") { | |
continue; | |
} | |
if (response.which == "positive") { | |
addGist(); | |
continue; | |
} | |
if (response.which == "negative") { | |
if (!sel || sel.length < 1) { | |
toast("Nothing to delete."); | |
continue; | |
} | |
var desc = sel.length > 1 ? sel.length + " gists" : settings.gists[gists[sel[0]]]; | |
var r = dlg( | |
app.title + " - Deleting gists", | |
"Delete " + desc + "?", | |
"Delete", "Cancel"); | |
if (!r || r.which == 'negative') continue; | |
for (var i = sel.length; i--;) { | |
delete settings.gists[gists[sel[i]]]; | |
} | |
saveSettings(); | |
} | |
} | |
} | |
/** | |
Show a dialog to get a zero-terminated string. | |
@param {Object} option | |
@return {Array.<number>} | |
*/ | |
inputHandler.sz = function(patch, option, offset) { | |
var chunk = option[offset], | |
maxLen = patch.initial[offset].length, | |
placeholder = '', | |
result, response; | |
while (1) { | |
result = []; | |
response = droid.dialogGetInput('' + chunk.title, '' + chunk.info, placeholder); | |
if (!response) continue; | |
placeholder = response; | |
if (response.length >= maxLen) { | |
toast("Exceeded maximum characters: " + (maxLen - 1)); | |
continue; | |
} | |
for (var i = 0; i < response.length; i++) { | |
result.push(response.charCodeAt(i)); | |
} | |
while (i++ < maxLen) { | |
result.push(0); | |
} | |
assert(result.length == maxLen, "getCustomSz returns array with correct length"); | |
return result; | |
} | |
}; | |
/** | |
Show a dialog to get a hexidecimal byte value. | |
@param {Object} option | |
@return {number} | |
*/ | |
inputHandler['byte'] = function(patch, option, offset) { | |
var chunk = option[offset], | |
placeholder = '', | |
response; | |
while (1) { | |
response = droid.dialogGetInput('' + chunk.title, '' + chunk.info, placeholder); | |
if (!response) continue; | |
placeholder = response.substring(0, 2); | |
if (response.length > 2) { | |
continue; | |
} | |
return [readNum(response)]; | |
} | |
}; | |
function checkPatchUpdates() { | |
toast("Checking patch updates..."); | |
for (var key in settings.gists) { | |
loadGist(key); | |
} | |
} | |
// | |
// MAIN | |
// | |
/** | |
Program entrypoint. Called from the loader script. | |
*/ | |
function main(storage) { | |
var patchId = -1, | |
// targetId = -1, | |
manageId = -1, | |
options, | |
response; | |
load("/sdcard/com.googlecode.rhinoforandroid/extras/rhino/android.js"); | |
droid = new Android(); | |
toast("\n" + app.title + " v" + app.version + "\n"); | |
if (storage) settings.storage = storage; | |
loadSettings(); | |
checkPatchUpdates(); | |
patch(); | |
} | |
}()); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment