20 | {% else %}
21 | (no response yet)
22 | {% endif %}
23 |
--------------------------------------------------------------------------------
/pmca/firmware/__init__.py:
--------------------------------------------------------------------------------
1 | """Firmware data file parser"""
2 |
3 | import os
4 | from ..util import *
5 |
6 | DatHeader = Struct('DatHeader', [
7 | ('magic', Struct.STR % 8),
8 | ])
9 | datHeaderMagic = b'\x89\x55\x46\x55\x0d\x0a\x1a\x0a'
10 |
11 | DatChunkHeader = Struct('DatChunkHeader', [
12 | ('size', Struct.INT32),
13 | ('type', Struct.STR % 4),
14 | ], Struct.BIG_ENDIAN)
15 |
16 | def readDat(file):
17 | """"Extract the firmware from a .dat file. Returns the offset and the size of the firmware data."""
18 | file.seek(0)
19 | header = DatHeader.unpack(file.read(DatHeader.size))
20 | if header.magic != datHeaderMagic:
21 | raise Exception('Wrong magic')
22 |
23 | while True:
24 | data = file.read(DatChunkHeader.size)
25 | if len(data) != DatChunkHeader.size:
26 | break
27 | chunk = DatChunkHeader.unpack(data)
28 | if chunk.type == b'FDAT':
29 | return file.tell(), chunk.size
30 | file.seek(chunk.size, os.SEEK_CUR)
31 | raise Exception('FDAT chunk not found')
32 |
--------------------------------------------------------------------------------
/appveyor.yml:
--------------------------------------------------------------------------------
1 | # Run windows builds on appveyor.com
2 |
3 | version: "Build #{build}"
4 |
5 | environment:
6 | PYTHON: C:\Python35
7 |
8 | install:
9 | # Path setup
10 | - set PATH=%PYTHON%;%PYTHON%\Scripts;%APPVEYOR_BUILD_FOLDER%;%PATH%
11 |
12 | # Install dependencies
13 | - pip install -r requirements.txt
14 |
15 | # Log versions
16 | - python --version
17 | - pip list
18 |
19 | # Download libusb
20 | - appveyor DownloadFile http://jaist.dl.sourceforge.net/project/libusb-win32/libusb-win32-releases/1.2.6.0/libusb-win32-bin-1.2.6.0.zip
21 | - 7z x libusb-win32-bin-1.2.6.0.zip
22 | - move libusb-win32-bin-1.2.6.0\bin\x86\libusb0_x86.dll libusb0.dll
23 |
24 | build: off
25 |
26 | after_test:
27 | # Run pyinstaller
28 | - pyinstaller pmca-console.spec
29 | - pyinstaller pmca-gui.spec
30 |
31 | artifacts:
32 | - path: dist\*
33 |
34 | deploy:
35 | # Deploy tagged releases
36 | - provider: GitHub
37 | auth_token: {secure: fKuIKISPj15kzBq1zlLtAyscXK43FG6oQQPMw8k8rsbVXRYZfCYtK3XSn6AQDlip}
38 | artifact: /.*/
39 | on: {appveyor_repo_tag: true}
40 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 ma1co
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/pmca/xpd/__init__.py:
--------------------------------------------------------------------------------
1 | """Methods for reading and writing xpd files"""
2 |
3 | from Crypto.Hash import HMAC, SHA256
4 | import sys
5 |
6 | try:
7 | from configparser import ConfigParser
8 | except ImportError:
9 | # Python 2
10 | from ConfigParser import ConfigParser
11 |
12 | if sys.version_info >= (3,):
13 | from io import StringIO
14 | else:
15 | # Python 2
16 | from StringIO import StringIO
17 |
18 | from . import constants
19 |
20 | def parse(data):
21 | """Parses an xpd file
22 |
23 | Returns:
24 | A dict containing the properties
25 | """
26 | config = ConfigParser()
27 | config.optionxform = str
28 | config.read_file(StringIO(data.decode('latin1')))
29 | return dict(config.items(constants.sectionName))
30 |
31 | def dump(items):
32 | """Builds an xpd file"""
33 | config = ConfigParser()
34 | config.optionxform = str
35 | config.add_section(constants.sectionName)
36 | for k, v in items.items():
37 | config.set(constants.sectionName, k, v)
38 | f = StringIO()
39 | config.write(f)
40 | return f.getvalue().encode('latin1')
41 |
42 | def calculateChecksum(data):
43 | """The function used to calculate CIC checksums for xpd files"""
44 | return HMAC.new(constants.cicKey, data, SHA256).hexdigest()
45 |
--------------------------------------------------------------------------------
/static/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: sans-serif;
3 | font-size: 14px;
4 | margin: 0;
5 | line-height: 1.5em;
6 | }
7 |
8 | h1 {
9 | font-size: 2em;
10 | margin: 1em 0 .5em;
11 | }
12 |
13 | h2 {
14 | font-size: 1.5em;
15 | margin: 1em 0 .5em;
16 | }
17 |
18 | p {
19 | margin: 0 0 1em;
20 | }
21 |
22 | a {
23 | color: #000;
24 | }
25 |
26 | label {
27 | display: block;
28 | }
29 |
30 | .wrapper {
31 | max-width: 600px;
32 | margin: 0 auto;
33 | padding: 15px;
34 | }
35 |
36 | .header {
37 | font-size: 3em;
38 | line-height: 1em;
39 | font-weight: bold;
40 | }
41 |
42 | .nav {
43 | margin: 10px -10px 20px;
44 | line-height: 2em;
45 | border: 1px #aaa;
46 | border-style: solid none solid;
47 | }
48 |
49 | .nav a {
50 | text-decoration: none;
51 | margin: 0 10px;
52 | }
53 |
54 | .github {
55 | position: absolute;
56 | top: 0;
57 | right: 0;
58 | width: 149px;
59 | height: 149px;
60 | background: url(https://aral.github.io/fork-me-on-github-retina-ribbons/right-graphite.png);
61 | }
62 |
63 | .box {
64 | background: #eee;
65 | padding: 10px;
66 | height: 200px;
67 | overflow: auto;
68 | font-family: monospace;
69 | font-size: .8em;
70 | line-height: 1.5em;
71 | }
72 |
73 | .apps li {
74 | margin-bottom: 1em;
75 | }
76 |
77 | .small, .small a {
78 | font-size: .8em;
79 | color: #aaa;
80 | }
81 |
--------------------------------------------------------------------------------
/templates/home.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% set title = 'Home' %}
3 | {% block content %}
4 |
Home
5 |
The latest Sony cameras include an Android subsystem used to run apps from the proprietary Sony PlayMemories Camera App Store (PMCA). We reverse engineered the installation process. This allows you to install custom Android apps on your camera.
Upload your own Android app to install it on your camera. Or try the demo app!
13 |
14 |
Caution!
15 |
This is an experiment in a very early stage. All information has been found through reverse engineering. Even though everything worked fine for our developers, it could cause harm to your hardware. If you break your camera, you get to keep both pieces. We won't take any responsability.
16 |
However, we're just interacting with the isolated Android subsystem. Apps you install this way can be uninstalled in the applications manager. In the worst case, a factory reset of the camera clears all data on the Android partitions.
This page allows you to communicate with your camera through the USB connection of your computer. Make sure you switch your camera to MTP mode, connect it via USB and turn it on.
14 |
If you haven't installed the PMCA Downloader plugin, install it here.
15 |
If you have problems, please try it again using Internet Explorer.
27 | {% set completed = false %}
28 | {% include 'task/view.html' %}
29 |
30 |
31 |
Log
32 |
33 | {% endblock %}
34 |
--------------------------------------------------------------------------------
/pmca/util/__init__.py:
--------------------------------------------------------------------------------
1 | """Some utility functions to pack and unpack integers"""
2 |
3 | import struct
4 | from collections import namedtuple
5 |
6 | def parse32le(data):
7 | return struct.unpack('I', data)[0]
14 |
15 | def dump32be(value):
16 | return struct.pack('>I', value)
17 |
18 | def parse16le(data):
19 | return struct.unpack('H', data)[0]
26 |
27 | def dump16be(value):
28 | return struct.pack('>H', value)
29 |
30 | def parse8(data):
31 | return struct.unpack('B', data)[0]
32 |
33 | def dump8(value):
34 | return struct.pack('B', value)
35 |
36 | class Struct(object):
37 | LITTLE_ENDIAN = '<'
38 | BIG_ENDIAN = '>'
39 | PADDING = '%dx'
40 | CHAR = 'c'
41 | STR = '%ds'
42 | INT64 = 'Q'
43 | INT32 = 'I'
44 | INT16 = 'H'
45 | INT8 = 'B'
46 |
47 | def __init__(self, name, fields, byteorder=LITTLE_ENDIAN):
48 | self.tuple = namedtuple(name, (n for n, fmt in fields if not isinstance(fmt, int)))
49 | self.format = byteorder + ''.join(self.PADDING % fmt if isinstance(fmt, int) else fmt for n, fmt in fields)
50 | self.size = struct.calcsize(self.format)
51 |
52 | def unpack(self, data, offset = 0):
53 | return self.tuple(*struct.unpack_from(self.format, data, offset))
54 |
55 | def pack(self, **kwargs):
56 | return struct.pack(self.format, *self.tuple(**kwargs))
57 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | # Run os x builds on travis-ci.org
2 |
3 | os: osx
4 | language: generic
5 |
6 | before_install:
7 | # Update brew
8 | - brew update
9 | - brew --version
10 |
11 | # Install python
12 | - brew install pyenv || brew upgrade pyenv
13 | - eval "$(pyenv init -)"
14 | - env PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install 3.5.2
15 | - pyenv local 3.5.2
16 |
17 | # Install libusb
18 | - brew install libusb || brew upgrade libusb
19 |
20 | install:
21 | # Install dependencies
22 | - pip install -r requirements.txt
23 |
24 | # Log versions
25 | - python --version
26 | - pip list
27 |
28 | script:
29 | # Run pyinstaller
30 | - pyinstaller pmca-console.spec
31 | - pyinstaller pmca-gui.spec
32 |
33 | deploy:
34 | # Deploy tagged releases
35 | provider: releases
36 | api_key: {secure: PWMg2dhPJgK4mww9yJcz3myo6oZwSMZU+2MvwDnvMvdZTaUPexHeiszX7RwBpzLNgPB+uJ7dgnjYJLm+LhgVgmeOhioIw9GDzVaC0rwfVVp94Q1tTHoXNlaDAfXQobY5p5avdjA2lSpvTuMDeiuAz2synsU5xmEcVbnhMtoMi8bsm4Nly63519yNkjyaYQU6BKR1lpHy9fh952YFcL2f4vRMYeq2aB3YLBCP1n9AkuocNEXoP+U03Ol+NpMWYcwBJGDunDo7dXkXSlBN5H4Re8JhAcKxfbuvnlkgb+bm+0OyOl0ePHPLOvShxeQlnI6A8NdmBBHLPBE2IIJw4pkhiU5wXvt70/teJLzww/90jNUNw+O7Z4e+HdBCLGENv4c9TnDnqU1RJKmcnWxWIbUNuCAzIsT5NGY7/lrOvlNJTVJ8O/dfmE/mRcGTJsXZyGEviysiLax56mRK4K07l0d5g6giPk0DadIBkANoBKzp4NDeB1E8VC7p0tAJZ1MaYHK8qEGelgtEFxnQm3NVnMv8tLZZEKLPCwgTJZTnyX+oJ/UxfHqO23hw+ntpRL9+dlLQouKnck+FEmGwYUUmIRfzGA21jpBprahEXENtn0efSd0fbYG0PvkrQRA/C4QuLOTkYgWJHWO+9dOww21rJ7YY4sp5yRMeXaBYj1zglUxyecY=}
37 | file_glob: true
38 | file: dist/*
39 | skip_cleanup: true
40 | on: {tags: true}
41 |
--------------------------------------------------------------------------------
/pmca/commands/market.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function
2 | from getpass import getpass
3 | import os
4 | import re
5 | import sys
6 |
7 | if sys.version_info < (3,):
8 | # Python 2
9 | input = raw_input
10 |
11 | from .. import marketclient
12 | from .. import spk
13 |
14 | def marketCommand(token=None):
15 | if not token:
16 | print('Please enter your Sony Entertainment Network credentials\n')
17 | email = input('Email: ')
18 | password = getpass('Password: ')
19 | token = marketclient.login(email, password)
20 | if token:
21 | print('Login successful. Your auth token (use with the -t option):\n%s\n' % token)
22 | else:
23 | print('Login failed')
24 | return
25 |
26 | devices = marketclient.getDevices(token)
27 | print('%d devices found\n' % len(devices))
28 |
29 | apps = []
30 | for device in devices:
31 | print('%s (%s)' % (device.name, device.serial))
32 | for app in marketclient.getApps(device.name):
33 | if not app.price:
34 | apps.append((device.deviceid, app.id))
35 | print(' [%2d] %s' % (len(apps), app.name))
36 | print('')
37 |
38 | if apps:
39 | while True:
40 | i = int(input('Enter number of app to download (0 to exit): '))
41 | if i == 0:
42 | break
43 | app = apps[i - 1]
44 | print('Downloading app %s' % app[1])
45 | spkName, spkData = marketclient.download(token, app[0], app[1])
46 | fn = re.sub('(%s)?$' % re.escape(spk.constants.extension), '.apk', spkName)
47 | data = spk.parse(spkData)
48 |
49 | if os.path.exists(fn):
50 | print('File %s exists already' % fn)
51 | else:
52 | with open(fn, 'wb') as f:
53 | f.write(data)
54 | print('App written to %s' % fn)
55 | print('')
56 |
--------------------------------------------------------------------------------
/pmca/marketserver/__init__.py:
--------------------------------------------------------------------------------
1 | """Provides the server part of the functionality used to download apps over USB"""
2 |
3 | import json
4 |
5 | from . import constants
6 | from .. import xpd
7 |
8 | def parsePostData(data):
9 | """Parses the data submitted by the camera to the portal url
10 |
11 | Returns:
12 | {
13 | 'accountinfo': {
14 | 'signinid': 'appstore email address',
15 | 'accountid': 'appstore portal id',
16 | 'registered': '1',
17 | },
18 | 'applications': [{
19 | 'name': 'application name',
20 | 'version': 'application version',
21 | }, ...],
22 | 'deviceinfo': {
23 | 'deviceid': 'device id',
24 | 'fwversion': 'firmware version',
25 | 'productcode': 'product code',
26 | 'name': 'camera name',
27 | 'battery': 'battery level',
28 | 'freespace': 'free space',
29 | 'storagetotalsize': 'total space',
30 | },
31 | 'session': {
32 | 'correlationid': 'correlation id',
33 | },
34 | 'profileversion': {
35 | 'version': '1',
36 | },
37 | }
38 | """
39 | return json.loads(data.decode('latin1'))
40 |
41 | def getXpdResponse(correlation, url):
42 | """Creates an xpd file which points the camera to the supplied portal url"""
43 | return xpd.dump({
44 | 'TCD': url,
45 | 'TKN': correlation,
46 | 'CIC': xpd.calculateChecksum(url.encode('latin1'))
47 | })
48 |
49 | def getJsonInstallResponse(appName, spkUrl):
50 | """Creates the response that has to be returned by the portal url to install the given app"""
51 | return json.dumps({"actions": [{
52 | "command": "dlandinstall",
53 | "args": spkUrl,
54 | "attrs": [{
55 | "attrname": "appname",
56 | "attrvalue": appName,
57 | }],
58 | }]}).encode('latin1')
59 |
60 | def getJsonResponse():
61 | """Creates the response that has to be returned by the portal url if no more actions have to be taken"""
62 | return json.dumps({"actions": []}).encode('latin1')
63 |
--------------------------------------------------------------------------------
/pmca/spk/constants.py:
--------------------------------------------------------------------------------
1 | mimeType = 'application/vnd.sony.spk.package-archive'
2 | extension = '.1.spk'
3 | blockSize = 0x100000
4 | paddingSize = 16
5 |
6 | # ScalarAInstaller.apk/lib/librsaforinstaller.so
7 | rsaModulus = 18546786124031929736450043032989194943881708896956310049376885815353452452737129160084493534424259917885859685404970368509019605185259963806563452189947051455437552314477916953143062044204000233355686480383169448909078186398717520581109145341225470292554937903142958836844346971293647348222873983594916965142879437008833178868483114882377493704135238091195859303789511256231440821207663503100500629361938642335839866157113610679689132138679971646420026197437282015245989139338190682985702090970027710310551550127193083201786677858304822056545507776969720889751356415799936270667430411556285015304886252479659945984429
8 | rsaExponent = 65537
9 |
10 | # We need a string which gives a 128bit AES key when decrypted with RSA.
11 | # Luckily, such a string is provided in every spk file from the appstore.
12 | # Just copy 256 bytes, starting at 0x0C.
13 | # This one is taken from TouchLessShutter100.1.spk
14 | sampleSpkKey = b'\x7e\x29\x35\x14\x25\xec\x82\xc6\x1e\xf1\xd7\x36\xaf\xad\xc2\x80\x96\x6a\x2d\xad\xd5\x3f\xfe\xe3\xd5\x5e\x60\x8a\xfa\xd4\x39\x51\x85\x3a\x1b\xe9\xe3\x62\x65\xb0\x5c\x1e\x43\x45\xac\x49\x19\xd6\xc9\xef\x4e\x02\x1f\x58\xbf\x85\xe4\x85\x1c\x3c\xb2\xbd\x14\x41\x6d\x26\x15\x26\x29\x65\x23\x25\x96\x64\xbe\x8a\xc2\x47\x7f\x7b\xd6\xd1\xc1\x62\xaf\x28\x6c\x65\xa7\xf5\x7a\x18\x00\xe4\x89\xcf\x24\xfc\x58\xfb\x04\x1f\x29\xea\x10\x3f\x5f\xca\x3e\xb9\x96\xba\xaf\x8e\xea\x2c\xfd\x64\x31\xbb\x76\x5d\xce\xd8\x11\x0b\x34\x1d\xb6\xd3\x13\xdc\x40\xa8\x2a\x6e\x21\x34\x27\x2e\x3a\xa3\xc7\x57\x0b\x80\xd5\xd1\xd7\x53\x3f\x2e\xfc\xf3\xff\x0a\x00\xf9\x16\x91\x84\x74\x17\x00\x5d\xa8\x41\xfd\x29\x06\xac\x7e\x07\x67\xfa\xfb\xbb\x1e\x7e\xa0\xe1\xc7\x68\x28\xac\xe6\x6f\xdd\x44\x95\x06\x60\x9c\xc6\x04\xc8\xab\xf1\x1d\x94\x14\xa7\xcb\x29\x6d\x93\x84\x1b\x3a\x06\x2c\x05\xe5\xa3\xf3\x40\x55\x6d\xc2\x5e\x6c\x0e\x46\x74\x66\x24\x2f\xf4\x33\xcc\x20\x0e\xf9\xf2\x9d\x9b\xbd\xf6\x22\x72\x12\xef\x40\xbc\x43\xcb\x74\xb1\xdf\x02\xbe\x31\x53\xa3\x34\xa2'
15 |
--------------------------------------------------------------------------------
/pmca/ui/__init__.py:
--------------------------------------------------------------------------------
1 | """Gui related classes"""
2 | import threading
3 |
4 | try:
5 | from queue import Queue, Empty
6 | from tkinter import *
7 | from tkinter.ttk import *
8 | from tkinter.filedialog import askopenfilename
9 | except ImportError:
10 | # Python 2
11 | from Queue import Queue, Empty
12 | from Tkinter import *
13 | from ttk import *
14 | from tkFileDialog import askopenfilename
15 |
16 | class UiRoot(Tk):
17 | def __init__(self):
18 | Tk.__init__(self)
19 | self._queue = Queue()
20 | self._processQueue()
21 |
22 | def run(self, func):
23 | """Post functions to run them on the main thread"""
24 | self._queue.put(func)
25 |
26 | def _processQueue(self):
27 | while True:
28 | try:
29 | self._queue.get(block=False)()
30 | except Empty:
31 | break
32 | self.after(100, self._processQueue)
33 |
34 |
35 | class UiFrame(Frame):
36 | def __init__(self, parent, **kwargs):
37 | Frame.__init__(self, parent, **kwargs)
38 | self._parent = parent
39 |
40 | def run(self, func):
41 | self._parent.run(func)
42 |
43 |
44 | class BackgroundTask(object):
45 | """Similar to Android's AsyncTask"""
46 | def __init__(self, ui):
47 | self.ui = ui
48 |
49 | def doBefore(self):
50 | """Runs on the main thread, returns arg"""
51 | pass
52 | def do(self, arg):
53 | """Runs on a separate thread, returns result"""
54 | pass
55 | def doAfter(self, result):
56 | """Runs on the main thread again"""
57 | pass
58 |
59 | def run(self):
60 | """Invoke this on the main thread only"""
61 | arg = self.doBefore()
62 | threading.Thread(target=self._onThread, args=[arg]).start()
63 |
64 | def _onThread(self, arg):
65 | result = self.do(arg)
66 | self.ui.run(lambda: self.doAfter(result))
67 |
68 |
69 | class ScrollingText(Frame):
70 | """A wrapper for a Text widget with a scrollbar"""
71 | def __init__(self, parent):
72 | Frame.__init__(self, parent)
73 | Grid.columnconfigure(self, 0, weight=1)
74 | Grid.rowconfigure(self, 0, weight=1)
75 | self.scrollbar = Scrollbar(self)
76 | self.scrollbar.grid(row=0, column=1, sticky=N+S)
77 | self.text = Text(self, yscrollcommand=self.scrollbar.set)
78 | self.text.grid(row=0, column=0, sticky=N+S+W+E)
79 | self.scrollbar.config(command=self.text.yview)
80 |
--------------------------------------------------------------------------------
/pmca/util/http.py:
--------------------------------------------------------------------------------
1 | """Some methods to make HTTP requests"""
2 |
3 | from collections import namedtuple
4 | import random
5 | import string
6 |
7 | try:
8 | from urllib.parse import *
9 | from urllib.request import *
10 | from http.cookiejar import *
11 | except ImportError:
12 | # Python 2
13 | from urllib import *
14 | from urllib2 import *
15 | from cookielib import *
16 | from urlparse import *
17 |
18 | HttpResponse = namedtuple('HttpResponse', 'url, data, raw_data, headers, cookies')
19 |
20 | def postForm(url, data, headers={}, cookies={}, auth=None):
21 | return post(url, urlencode(data).encode('latin1'), headers, cookies, auth)
22 |
23 | def postFile(url, fileName, fileData, fieldName='', headers={}, cookies={}, auth=None):
24 | boundary = ''.join(random.choice('-_' + string.digits + string.ascii_letters) for _ in range(40))
25 | headers['Content-type'] = 'multipart/form-data; boundary=%s' % boundary
26 | data = b'\r\n'.join([
27 | b'--' + boundary.encode('latin1'),
28 | b'Content-Disposition: form-data; name="' + fieldName.encode('latin1') + b'"; filename="' + fileName.encode('latin1') + b'"',
29 | b'',
30 | fileData,
31 | b'--' + boundary.encode('latin1') + b'--',
32 | b'',
33 | ])
34 | return request(url, data, headers, cookies, auth)
35 |
36 | def get(url, data={}, headers={}, cookies={}, auth=None):
37 | if data:
38 | url += '?' + urlencode(data)
39 | return request(url, None, headers, cookies, auth)
40 |
41 | def post(url, data, headers={}, cookies={}, auth=None):
42 | return request(url, data, headers, cookies, auth)
43 |
44 | def request(url, data=None, headers={}, cookies={}, auth=None):
45 | if cookies:
46 | headers['Cookie'] = '; '.join(quote(k) + '=' + quote(v) for (k, v) in cookies.items())
47 | request = Request(str(url), data, headers)
48 | manager = HTTPPasswordMgrWithDefaultRealm()
49 | if auth:
50 | manager.add_password(None, request.get_full_url(), auth[0], auth[1])
51 | handlers = [HTTPBasicAuthHandler(manager), HTTPDigestAuthHandler(manager)]
52 | try:
53 | import certifi, ssl
54 | handlers.append(HTTPSHandler(context=ssl.create_default_context(cafile=certifi.where())))
55 | except:
56 | # App engine
57 | pass
58 | response = build_opener(*handlers).open(request)
59 | cj = CookieJar()
60 | cj.extract_cookies(response, request)
61 | headers = dict(response.headers)
62 | raw_contents = response.read()
63 | contents = raw_contents.decode(headers.get('charset', 'latin1'))
64 | return HttpResponse(urlparse(response.geturl()), contents, raw_contents, headers, dict((c.name, c.value) for c in cj))
65 |
--------------------------------------------------------------------------------
/docs/AppInstallation.md:
--------------------------------------------------------------------------------
1 | ## App installation ##
2 | This describes how Android apps are installed on Sony cameras.
3 |
4 | ### Where to begin... ###
5 | If you want to dig into the firmware yourself, here's what to do:
6 |
7 | 1. Use [fwtool](https://github.com/ma1co/fwtool.py) to unpack your favorite camera's firmware
8 | 2. One of the images in the *0700\_part_image* directory contains the Android partition with the interesting apps in the *app* directory
9 |
10 | ### ScalarAMarket ###
11 | This app is used to download apps directly on your camera via the built-in wifi.
12 | It is basically a WebKit wrapper which allows you to access the Sony app store with the special User-Agent header `Mozilla/5.0 (Build/sccamera)`. The requests are redirected to `wifi***.php`. As soon as you decide to download an app, an *xpd* file is loaded which contains the URL to an *spk* file. Using hard coded http authentication, the *spk* file is downloaded.
13 |
14 | ### ScalarAInstaller ###
15 | An *spk* file is a container for an AES encrypted *apk* file. An RSA encrypted version of the key is contained, too. This app decrypts the *apk* data and installs it using the package manager.
16 |
17 | ### ScalarAUsbDlApp ###
18 | This is the most interesting app, because it handles the communication when installing apps through your computer via USB. On the computer side, things are handled directly in your browser through the PMCA Downloader plugin.
19 |
20 | The typical app install process happens as follows:
21 |
22 | 1. You visit the Sony app store using the `usb***.php` urls and decide to download an app to your camera.
23 | 2. A new task sequence is generated, with a unique identifier, the *correlation id*
24 | 3. The plugin downloads an *xpd* file which contains the *portal url* and the
25 | *correlation id*. The file is sent to the camera. (An *xpd* file is an *ini* file which contains some data and an hmac sha256 checksum.)
26 | 4. The camera sends a camera status JSON object (containing serial number, battery level, installed apps, *correlation id*, etc.) to the *portal url* using hard coded authentication. The network data is transmitted via USB. However, everything is SSL encrypted directly in camera. Only https connections signed by a known root CA are allowed. However, the certificate is still accepted if it is expired, revoked and for a different host.
27 | 5. The server replies with a JSON object containing the next actions to take. This includes the URL of the *spk* package to install, which is again downloaded over https.
28 |
29 | ### Summary ###
30 | No origin verification is performed in the whole process. This allows us to replicate the behavior of the Sony app store.
31 |
--------------------------------------------------------------------------------
/pmca/spk/__init__.py:
--------------------------------------------------------------------------------
1 | """Methods for reading and writing spk files"""
2 |
3 | from Crypto.Cipher import AES
4 | from Crypto.PublicKey import RSA
5 | from Crypto.Util.number import bytes_to_long, long_to_bytes
6 | import sys
7 |
8 | if sys.version_info >= (3,):
9 | long = int
10 |
11 | from . import constants
12 | from . import util
13 | from ..util import *
14 |
15 | SpkHeader = Struct('SpkHeader', [
16 | ('magic', Struct.STR % 4),
17 | ('keyOffset', Struct.INT32),
18 | ])
19 | spkHeaderMagic = b'1spk'
20 |
21 | SpkKeyHeader = Struct('SpkKeyHeader', [
22 | ('keySize', Struct.INT32),
23 | ])
24 |
25 | def parse(data):
26 | """Parses an spk file
27 |
28 | Returns:
29 | The contained apk data
30 | """
31 | encryptedKey, encryptedData = parseContainer(data)
32 | key = decryptKey(encryptedKey)
33 | return decryptData(key, encryptedData)
34 |
35 | def dump(data):
36 | """Builds an spk file containing the apk data specified"""
37 | encryptedKey = constants.sampleSpkKey
38 | key = decryptKey(encryptedKey)
39 | encryptedData = encryptData(key, data)
40 | return dumpContainer(encryptedKey, encryptedData)
41 |
42 | def isSpk(data):
43 | return len(data) >= SpkHeader.size and SpkHeader.unpack(data).magic == spkHeaderMagic
44 |
45 | def parseContainer(data):
46 | """Parses an spk file
47 |
48 | Returns:
49 | ('encrypted key', 'encrypted apk data')
50 | """
51 | header = SpkHeader.unpack(data)
52 | if header.magic != spkHeaderMagic:
53 | raise Exception('Wrong magic')
54 | keyHeaderOffset = SpkHeader.size + header.keyOffset
55 | keyHeader = SpkKeyHeader.unpack(data, keyHeaderOffset)
56 | keyOffset = keyHeaderOffset + SpkKeyHeader.size
57 | dataOffset = keyOffset + keyHeader.keySize
58 | return data[keyOffset:dataOffset], data[dataOffset:]
59 |
60 | def dumpContainer(encryptedKey, encryptedData):
61 | """Builds an spk file from the encrypted key and data specified"""
62 | return SpkHeader.pack(magic=spkHeaderMagic, keyOffset=0) + SpkKeyHeader.pack(keySize=len(encryptedKey)) + encryptedKey + encryptedData
63 |
64 | def decryptKey(encryptedKey):
65 | """Decrypts an RSA-encrypted key"""
66 | rsa = RSA.construct((long(constants.rsaModulus), long(constants.rsaExponent)))
67 | try:
68 | return rsa.encrypt(encryptedKey, 0)[0]
69 | except NotImplementedError:
70 | # pycryptodome
71 | return long_to_bytes(rsa._encrypt(bytes_to_long(encryptedKey)))
72 |
73 | def decryptData(key, encryptedData):
74 | """Decrypts the apk data using the specified AES key"""
75 | aes = AES.new(key, AES.MODE_ECB)
76 | return b''.join(util.unpad(aes.decrypt(c)) for c in util.chunk(encryptedData, constants.blockSize + constants.paddingSize))
77 |
78 | def encryptData(key, data):
79 | """Encrypts the apk data using the specified AES key"""
80 | aes = AES.new(key, AES.MODE_ECB)
81 | return b''.join(aes.encrypt(util.pad(c, constants.paddingSize)) for c in util.chunk(data, constants.blockSize))
82 |
--------------------------------------------------------------------------------
/pmca/appstore/__init__.py:
--------------------------------------------------------------------------------
1 | from collections import OrderedDict
2 | from datetime import datetime
3 | import yaml
4 |
5 | from .github import GithubApi
6 | from ..util import http
7 |
8 | class AppStore(object):
9 | def __init__(self, repo, branch='master', filename='apps.yaml'):
10 | self.repo = repo
11 | self.branch = branch
12 | self.filename = filename
13 |
14 | @property
15 | def apps(self):
16 | if not hasattr(self, '_apps'):
17 | apps = (self._createAppInstance(dict) for dict in self._loadApps())
18 | self._apps = OrderedDict((app.package, app) for app in apps)
19 | return self._apps
20 |
21 | def _loadApps(self):
22 | for doc in yaml.safe_load_all(self.repo.getFile(self.branch, self.filename)):
23 | if 'package' in doc and 'name' in doc:
24 | yield doc
25 |
26 | def _createAppInstance(self, dict):
27 | return App(self.repo, dict)
28 |
29 |
30 | class App(object):
31 | def __init__(self, repo, dict):
32 | self.repo = repo
33 | self.dict = dict
34 |
35 | def __getattr__(self, name):
36 | if name in ['package', 'author', 'name', 'desc', 'homepage']:
37 | return self.dict.get(name)
38 | raise AttributeError(name)
39 |
40 | @property
41 | def release(self):
42 | if not hasattr(self, '_release'):
43 | dict = self._loadRelease()
44 | self._release = self._createReleaseInstance(dict) if dict else None
45 | return self._release
46 |
47 | def _loadRelease(self):
48 | release = self.dict.get('release', {})
49 | if release.get('type') == 'github' and 'user' in release and 'repo' in release:
50 | for dict in GithubApi(release['user'], release['repo'], self.repo.client).getReleases():
51 | asset = self._findGithubAsset(dict.get('assets', []))
52 | if asset:
53 | return {
54 | 'version': dict.get('name') or dict.get('tag_name'),
55 | 'date': datetime.strptime(dict.get('created_at'), '%Y-%m-%dT%H:%M:%SZ'),
56 | 'desc': dict.get('body'),
57 | 'url': asset,
58 | }
59 | elif release.get('type') == 'yaml' and 'url' in release:
60 | for dict in yaml.safe_load_all(http.get(release['url']).data):
61 | if 'version' in dict and 'url' in dict:
62 | return dict
63 | elif release.get('type') == 'static' and 'version' in release and 'url' in release:
64 | return release
65 |
66 | def _findGithubAsset(self, assets, contentType='application/vnd.android.package-archive'):
67 | for asset in assets:
68 | if asset.get('content_type') == contentType:
69 | return asset.get('browser_download_url')
70 |
71 | def _createReleaseInstance(self, dict):
72 | return Release(self.package, dict)
73 |
74 |
75 | class Release(object):
76 | def __init__(self, package, dict):
77 | self.package = package
78 | self.dict = dict
79 |
80 | def __getattr__(self, name):
81 | if name in ['version', 'date', 'desc', 'url']:
82 | return self.dict.get(name)
83 | raise AttributeError(name)
84 |
85 | @property
86 | def asset(self):
87 | return self._loadAsset()
88 |
89 | def _loadAsset(self):
90 | return http.get(self.url).raw_data
91 |
--------------------------------------------------------------------------------
/pmca/marketclient/__init__.py:
--------------------------------------------------------------------------------
1 | """Provides a client to download apps from the PMCA store"""
2 |
3 | from collections import namedtuple
4 | import json
5 | import posixpath
6 | import re
7 |
8 | from . import constants
9 | from ..util import http
10 | from .. import xpd
11 |
12 | MarketDevice = namedtuple('MarketDevice', 'deviceid, name, serial')
13 | MarketApp = namedtuple('MarketApp', 'id, name, img, price, date')
14 |
15 | def download(portalid, deviceid, appid):
16 | """Downloads an app from the PMCA store
17 |
18 | Returns:
19 | ('file name', 'spk data')
20 | """
21 | xpdData = downloadXpd(portalid, deviceid, appid)
22 | name, url = parseXpd(xpdData)
23 | return downloadSpk(url)
24 |
25 | def login(email, password):
26 | """Tries to authenticate the specified user
27 |
28 | Returns:
29 | The portalid string in case of success or None otherwise
30 | """
31 | response = http.postForm(constants.loginUrl, data = {
32 | 'j_username': email,
33 | 'j_password': password,
34 | 'returnURL': constants.baseUrl + '/forward.php',
35 | })
36 | return response.cookies['portalid'] if 'portalid' in response.cookies else None
37 |
38 | def getDevices(portalid):
39 | """Fetches the list of devices for the current user"""
40 | data = json.loads(http.get(constants.baseUrl + '/dialog.php?case=mycamera', cookies = {
41 | 'portalid': portalid,
42 | 'localeid': constants.localeUs,
43 | }).data)
44 | contents = data['mycamera']['contents']
45 | r = re.compile('
.*?
(?P.*?)
.*?Serial:(?P.*?)', re.DOTALL)
46 | return [MarketDevice(**m.groupdict()) for m in r.finditer(contents)]
47 |
48 | def getPluginInstallText():
49 | """Fetches the English help text for installing the PMCA Downloader plugin"""
50 | data = json.loads(http.get(constants.baseUrl + '/dialog.php?case=installingPlugin', cookies = {
51 | 'localeid': constants.localeUs,
52 | }).data)
53 | contents = data['installingPlugin']['contents']
54 | r = re.compile('
(.*?)
', re.DOTALL)
55 | return r.search(contents).group(1)
56 |
57 | def getApps(devicename=None):
58 | """Fetches the list of apps compatible with the given device"""
59 | data = json.loads(http.get(constants.baseUrl + '/api/api_all_contents.php', data = {
60 | 'setname': devicename or '',
61 | }, headers = {
62 | 'X-Requested-With': 'XMLHttpRequest',
63 | }, cookies = {
64 | 'localeid': constants.localeUs,
65 | }).data)
66 | for app in data['contents']:
67 | yield MarketApp(app['app_id'], re.sub('\s+', ' ', app['app_name']), app['appimg_url'], None if app['app_price'] == 'Free' else app['app_price'], int(app['regist_date']))
68 |
69 | def downloadXpd(portalid, deviceid, appid):
70 | """Fetches the xpd file for the given app
71 |
72 | Returns:
73 | The contents of the xpd file
74 | """
75 | return http.get(constants.baseUrl + '/wifixpwd.php', data = {
76 | 'EID': appid,
77 | }, headers = {
78 | 'User-Agent': constants.cameraUserAgent,
79 | }, cookies = {
80 | 'portalid': portalid,
81 | 'deviceid': deviceid,
82 | 'localeid': constants.localeUs,
83 | }).raw_data
84 |
85 | def parseXpd(data):
86 | """Parses an xpd file
87 |
88 | Returns:
89 | ('file name', 'spk url')
90 | """
91 | config = xpd.parse(data)
92 | return config['FNAME'], config['OUS']
93 |
94 | def downloadSpk(url):
95 | """Downloads an spk file
96 |
97 | Returns:
98 | ('file name', 'spk data')
99 | """
100 | response = http.get(url, auth = (constants.downloadAuthUser, constants.downloadAuthPassword))
101 | return posixpath.basename(response.url.path), response.raw_data
102 |
--------------------------------------------------------------------------------
/pmca-console.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """A command line application to install apps on Android-enabled Sony cameras"""
3 | import argparse
4 |
5 | import config
6 | from pmca.commands.market import *
7 | from pmca.commands.usb import *
8 | from pmca import spk
9 |
10 | if getattr(sys, 'frozen', False):
11 | from frozenversion import version
12 | else:
13 | version = None
14 |
15 | def main():
16 | """Command line main"""
17 | parser = argparse.ArgumentParser()
18 | if version:
19 | parser.add_argument('--version', action='version', version=version)
20 | subparsers = parser.add_subparsers(dest='command', title='commands')
21 | info = subparsers.add_parser('info', description='Display information about the camera connected via USB')
22 | info.add_argument('-d', dest='driver', choices=['libusb', 'native'], help='specify the driver')
23 | install = subparsers.add_parser('install', description='Installs an apk file on the camera connected via USB. The connection can be tested without specifying a file.')
24 | install.add_argument('-s', dest='server', help='hostname for the remote server (set to empty to start a local server)', default=config.appengineServer)
25 | install.add_argument('-d', dest='driver', choices=['libusb', 'native'], help='specify the driver')
26 | install.add_argument('-o', dest='outFile', type=argparse.FileType('w'), help='write the output to this file')
27 | installMode = install.add_mutually_exclusive_group()
28 | installMode.add_argument('-f', dest='apkFile', type=argparse.FileType('rb'), help='install an apk file')
29 | installMode.add_argument('-a', dest='appPackage', help='the package name of an app from the app list')
30 | installMode.add_argument('-i', dest='appInteractive', action='store_true', help='select an app from the app list (interactive)')
31 | market = subparsers.add_parser('market', description='Download apps from the official Sony app store')
32 | market.add_argument('-t', dest='token', help='Specify an auth token')
33 | apk2spk = subparsers.add_parser('apk2spk', description='Convert apk to spk')
34 | apk2spk.add_argument('inFile', metavar='app.apk', type=argparse.FileType('rb'), help='the apk file to convert')
35 | apk2spk.add_argument('outFile', metavar='app' + spk.constants.extension, type=argparse.FileType('wb'), help='the output spk file')
36 | spk2apk = subparsers.add_parser('spk2apk', description='Convert spk to apk')
37 | spk2apk.add_argument('inFile', metavar='app' + spk.constants.extension, type=argparse.FileType('rb'), help='the spk file to convert')
38 | spk2apk.add_argument('outFile', metavar='app.apk', type=argparse.FileType('wb'), help='the output apk file')
39 | firmware = subparsers.add_parser('firmware', description='Update the firmware')
40 | firmware.add_argument('-f', dest='datFile', type=argparse.FileType('rb'), required=True, help='the firmware file')
41 | firmware.add_argument('-d', dest='driver', choices=['libusb', 'native'], help='specify the driver')
42 |
43 | args = parser.parse_args()
44 | if args.command == 'info':
45 | infoCommand(config.appengineServer, args.driver)
46 | elif args.command == 'install':
47 | if args.appInteractive:
48 | pkg = appSelectionCommand(args.server)
49 | if not pkg:
50 | return
51 | else:
52 | pkg = args.appPackage
53 | installCommand(args.server, args.driver, args.apkFile, pkg, args.outFile)
54 | elif args.command == 'market':
55 | marketCommand(args.token)
56 | elif args.command == 'apk2spk':
57 | args.outFile.write(spk.dump(args.inFile.read()))
58 | elif args.command == 'spk2apk':
59 | args.outFile.write(spk.parse(args.inFile.read()))
60 | elif args.command == 'firmware':
61 | firmwareUpdateCommand(args.datFile, args.driver)
62 | else:
63 | parser.print_usage()
64 |
65 |
66 | if __name__ == '__main__':
67 | main()
68 |
--------------------------------------------------------------------------------
/pmca/usb/__init__.py:
--------------------------------------------------------------------------------
1 | from collections import namedtuple
2 |
3 | from .driver import *
4 | from ..util import *
5 |
6 | MscDeviceInfo = namedtuple('MscDeviceInfo', 'manufacturer, model')
7 | MtpDeviceInfo = namedtuple('MtpDeviceInfo', 'manufacturer, model, serialNumber, operationsSupported, vendorExtension')
8 |
9 |
10 | class MscDevice(object):
11 | """Manages communication with a USB mass storage device"""
12 | MSC_OC_INQUIRY = 0x12
13 |
14 | def __init__(self, driver):
15 | self.driver = driver
16 | self.reset()
17 |
18 | def _checkResponse(self, sense):
19 | if sense != MSC_SENSE_OK:
20 | raise Exception('Mass storage error: Sense 0x%x 0x%x 0x%x' % sense)
21 |
22 | def reset(self):
23 | self.driver.reset()
24 | self.driver.sendCommand(6 * b'\0')
25 |
26 | def _sendInquiryCommand(self, size):
27 | response, data = self.driver.sendReadCommand(dump8(self.MSC_OC_INQUIRY) + 3*b'\0' + dump8(size) + b'\0', size)
28 | self._checkResponse(response)
29 | return data
30 |
31 | def getDeviceInfo(self):
32 | """SCSI Inquiry command"""
33 | l = 5 + parse8(self._sendInquiryCommand(5)[4:5])
34 | data = self._sendInquiryCommand(l)
35 |
36 | vendor = data[8:16].decode('latin1').rstrip()
37 | product = data[16:32].decode('latin1').rstrip()
38 | return MscDeviceInfo(vendor, product)
39 |
40 |
41 | class MtpDevice(object):
42 | """Manages communication with a PTP/MTP device. Inspired by libptp2"""
43 | PTP_OC_GetDeviceInfo = 0x1001
44 | PTP_OC_OpenSession = 0x1002
45 | PTP_OC_CloseSession = 0x1003
46 | PTP_RC_OK = 0x2001
47 | PTP_RC_SessionNotOpen = 0x2003
48 | PTP_RC_DeviceBusy = 0x2019
49 | PTP_RC_SessionAlreadyOpened = 0x201E
50 |
51 | def __init__(self, driver):
52 | self.driver = driver
53 | self.reset()
54 | self.openSession()
55 |
56 | def _checkResponse(self, code, acceptedCodes=[]):
57 | if code not in [self.PTP_RC_OK] + acceptedCodes:
58 | raise Exception('Response code not OK: 0x%x' % code)
59 |
60 | def _parseString(self, data, offset):
61 | length = parse8(data[offset:offset+1])
62 | offset += 1
63 | end = offset + 2*length
64 | return end, data[offset:end].decode('utf16')[:-1]
65 |
66 | def _parseIntArray(self, data, offset):
67 | length = parse32le(data[offset:offset+4])
68 | offset += 4
69 | end = offset + 2*length
70 | return end, [parse16le(data[o:o+2]) for o in range(offset, end, 2)]
71 |
72 | def _parseDeviceInfo(self, data):
73 | offset = 8
74 | offset, vendorExtension = self._parseString(data, offset)
75 | offset += 2
76 |
77 | offset, operationsSupported = self._parseIntArray(data, offset)
78 | offset, eventsSupported = self._parseIntArray(data, offset)
79 | offset, devicePropertiesSupported = self._parseIntArray(data, offset)
80 | offset, captureFormats = self._parseIntArray(data, offset)
81 | offset, imageFormats = self._parseIntArray(data, offset)
82 |
83 | offset, manufacturer = self._parseString(data, offset)
84 | offset, model = self._parseString(data, offset)
85 | offset, version = self._parseString(data, offset)
86 | offset, serial = self._parseString(data, offset)
87 |
88 | return MtpDeviceInfo(manufacturer, model, serial, set(operationsSupported), vendorExtension)
89 |
90 | def reset(self):
91 | self.driver.reset()
92 |
93 | def openSession(self, id=1):
94 | """Opens a new MTP session"""
95 | response = self.driver.sendCommand(self.PTP_OC_OpenSession, [id])
96 | self._checkResponse(response, [self.PTP_RC_SessionAlreadyOpened])
97 |
98 | def closeSession(self):
99 | """Closes the current session"""
100 | response = self.driver.sendCommand(self.PTP_OC_CloseSession, [])
101 | self._checkResponse(response, [self.PTP_RC_SessionNotOpen])
102 |
103 | def getDeviceInfo(self):
104 | """Gets and parses device information"""
105 | response, data = self.driver.sendReadCommand(self.PTP_OC_GetDeviceInfo, [])
106 | self._checkResponse(response)
107 | return self._parseDeviceInfo(data)
108 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Reverse engineering Sony PlayMemories Camera Apps #
2 | The latest Sony cameras include an Android subsystem used to run apps from the proprietary Sony PlayMemories Camera App Store (PMCA). We reverse engineered the installation process. This allows you to install custom Android apps on your camera.
3 |
4 | ## How to use it? ###
5 | There are two ways to install apps on your camera. Be sure it is connected over USB and set to MTP or mass storage mode.
6 |
7 | ### Browser plugin ###
8 | **Go to [sony-pmca.appspot.com](https://sony-pmca.appspot.com/) to try it out!** You can upload your own apps and install them to your camera using the official Sony browser plugin. Since other browser vendors are disabling NPAPI plugins, please try it using **Internet Explorer**.
9 |
10 | ### Local installer ###
11 | Download the [latest release](https://github.com/ma1co/Sony-PMCA-RE/releases/latest) (Windows or OS X) or clone this repository.
12 |
13 | #### Graphical user interface ####
14 | Run `pmca-gui` for a simple gui.
15 |
16 | #### Command line ####
17 | Run `pmca-console` in the command line for more options. Usage:
18 |
19 | * Test the USB connection to your camera (the result is written to the specified file):
20 |
21 | pmca-console install -o outfile.txt
22 |
23 | * Install an app from the app list:
24 |
25 | pmca-console install -i
26 |
27 | * Install an app on your camera (the app is uploaded and served from Google appengine):
28 |
29 | pmca-console install -f app.apk
30 |
31 | * Install an app using a local web server:
32 |
33 | pmca-console install -s "" -f app.apk
34 |
35 | * Download apps from the official Sony app store (interactive):
36 |
37 | pmca-console market
38 |
39 | * Update the camera's firmware:
40 |
41 | pmca-console firmware -f FirmwareData.dat
42 |
43 | #### Windows drivers ####
44 | On Windows, the choice defaults to the default Windows USB drivers. If you want to use libusb on Windows, you'll have to install generic drivers for your camera using [Zadig](http://zadig.akeo.ie/) (select *libusb-win32*). You can then run `pmca-console install -d libusb`.
45 |
46 | #### OS X drivers ####
47 | On OS X, to communicate with cameras in mass storage mode, the Sony [PMCADownloader](https://sony-pmca.appspot.com/plugin/install) browser plugin has to be installed.
48 |
49 | ## Is it safe? ##
50 | This is an experiment in a very early stage. All information has been found through reverse engineering. Even though everything worked fine for our developers, it could cause harm to your hardware. If you break your camera, you get to keep both pieces. **We won't take any responsibility.**
51 |
52 | However, this seems to be safer than tampering with your camera firmware directly. We're just interacting with the isolated Android subsystem. Apps you install this way can be uninstalled in the applications manager. In the worst case, a factory reset of the camera clears all data on the Android partitions.
53 |
54 | ## What about custom apps? ##
55 | You can try your standard Android apps from any app store, they should work more or less, your mileage may vary. Upload your apk file to our website and follow the instructions. Remember that the performance of the cameras is kind of limited. Some of them still run Android 2.3.7. So the Facebook app might not be the best choice.
56 |
57 | If you want to develop your custom app, feel free to do so. Debug and release certificates are accepted by the camera. There are a few special Sony APIs which allow you to take advantage of the features of your camera. Have a look at the [PMCADemo](https://github.com/ma1co/PMCADemo) project where we try to make sense of those.
58 |
59 | ## About this repository ##
60 | * **pmca-console.py**: Source for the USB installer console application. See the releases page for pyinstaller builds for Windows and OS X.
61 | * **pmca-gui.py**: A simple gui for pmca-console. See the releases page.
62 | * **main.py**: The source code for the Google App Engine website served at [sony-pmca.appspot.com](https://sony-pmca.appspot.com/).
63 | * **docs**: Technical documentation
64 |
65 | ## Special thanks ##
66 | Without the work done by the people at [nex-hack](http://www.personal-view.com/faqs/sony-hack/hack-development), this wouldn't have been possible. Thanks a lot!
67 |
--------------------------------------------------------------------------------
/pmca/installer/__init__.py:
--------------------------------------------------------------------------------
1 | """Manages the communication between camera, PC and appengine website during app installation"""
2 |
3 | from collections import namedtuple
4 | import json
5 | import select
6 | import socket
7 |
8 | from ..usb.sony import *
9 | from .. import xpd
10 |
11 | Response = namedtuple('Response', 'protocol, code, status, headers, data')
12 | Request = namedtuple('Request', 'protocol, method, url, headers, data')
13 |
14 | Status = namedtuple('Status', 'code, message, percent, totalSize')
15 | Result = namedtuple('Result', 'code, message')
16 |
17 | def _buildRequest(endpoint, contentType, data):
18 | return b'POST ' + endpoint.encode('latin1') + b' REST/1.0\r\nContent-type: ' + contentType.encode('latin1') + b'\r\n\r\n' + data
19 |
20 | def _parseHttp(data):
21 | headers, data = data.split(b'\r\n\r\n')[:2]
22 | headers = headers.decode('latin1').split('\r\n')
23 | firstLine = headers[0]
24 | headers = dict(h.split(': ') for h in headers[1:])
25 | return firstLine, headers, data
26 |
27 | def _parseRequest(data):
28 | firstLine, headers, data = _parseHttp(data)
29 | method, url, protocol = firstLine.split(' ', 2)
30 | return Request(protocol, method, url, headers, data)
31 |
32 | def _parseResponse(data):
33 | firstLine, headers, data = _parseHttp(data)
34 | protocol, code, status = firstLine.split(' ', 2)
35 | return Response(protocol, int(code), status, headers, data)
36 |
37 | def _parseResult(data):
38 | data = json.loads(data.decode('latin1'))
39 | return Result(data['resultCode'], data['message'])
40 |
41 | def _parseStatus(data):
42 | data = json.loads(data.decode('latin1'))
43 | return Status(data['status'], data['status text'], data['percent'], data['total size'])
44 |
45 | def install(dev, host, port, xpdData, statusFunc=None):
46 | """Sends an xpd file to the camera, lets it access the internet through SSL, waits for the response"""
47 | # Initialize communication
48 | dev.emptyBuffer()
49 | dev.sendInit()
50 |
51 | # Start the installation by sending the xpd data in a REST request
52 | response = dev.sendRequest(_buildRequest('/task/start', xpd.constants.mimeType, xpdData))
53 | response = _parseResponse(response)
54 | result = _parseResult(response.data)
55 | if result.code != 0:
56 | raise Exception('Response error %s' % str(result))
57 |
58 | connectionId = 0
59 | sock = None
60 |
61 | # Main loop
62 | while True:
63 | if sock is not None:
64 | ready = select.select([sock], [], [], 0)
65 | if ready[0]:
66 | # There is data waiting on the socket, let's send it to the camera
67 | resp = sock.recv(2 ** 14)
68 | if resp != b'':
69 | dev.sendSslData(connectionId, resp)
70 | else:
71 | dev.sendSslEnd(connectionId)
72 |
73 | # Receive the next message from the camera
74 | message = dev.receive()
75 | if message is None:
76 | # Nothing received, let's wait
77 | continue
78 |
79 | if isinstance(message, SslStartMessage):
80 | # The camera wants us to open an SSL socket
81 | connectionId = message.connectionId
82 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
83 | if message.host != host:
84 | raise Exception('Connecting to wrong host: %s' % message.host)
85 | sock.connect((host, port))
86 | elif isinstance(message, SslSendDataMessage) and sock and message.connectionId == connectionId:
87 | # The camera wants to send data over the socket
88 | sock.send(message.data)
89 | elif isinstance(message, SslEndMessage) and sock and message.connectionId == connectionId:
90 | # The camera wants to close the socket
91 | sock.close()
92 | sock = None
93 | elif isinstance(message, RequestMessage):
94 | # The camera sends a REST message
95 | request = _parseRequest(message.data)
96 | if request.url == '/task/progress':
97 | # Progress
98 | status = _parseStatus(request.data)
99 | if statusFunc:
100 | statusFunc(status)
101 | elif request.url == '/task/complete':
102 | # The camera completed the task, let's stop this loop
103 | result = _parseResult(request.data)
104 | dev.sendEnd()
105 | return result
106 | else:
107 | raise Exception("Unknown message url %s" % request.url)
108 | else:
109 | raise Exception("Unknown message %s" % str(message))
110 |
--------------------------------------------------------------------------------
/static/script.js:
--------------------------------------------------------------------------------
1 | (function() {
2 |
3 | function ajax(url, callback) {
4 | var xhr = new XMLHttpRequest();
5 | xhr.open("get", url, true);
6 | xhr.onreadystatechange = function() {
7 | if (xhr.readyState == 4 && xhr.status == 200)
8 | callback(xhr.responseText);
9 | };
10 | xhr.send(null);
11 | }
12 |
13 | function log(msg) {
14 | var ele = document.getElementById(state.logElementId);
15 | ele.innerHTML += '