diff --git a/ROADMAP.md b/ROADMAP.md
index 0203f9ec6e6a588afbf7fd13de292b0a53ad4ce6..f794a6ae35ec86966520c21e287156493512ee7b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -5,9 +5,9 @@
     * [x] Text
     * [ ] Media
 * Android Messages → Matrix
-  * [ ] Message content
+  * [x] Message content
     * [x] Text
-    * [ ] Media
+    * [x] Media
   * [x] Message history
     * [x] When creating portal
     * [x] Missed messages
@@ -15,6 +15,6 @@
   * [ ] Automatic portal creation
     * [x] At startup
     * [ ] When receiving invite or message
-  * [ ] Provisioning API for logging in
+  * [x] Provisioning API for logging in
   * [x] Use own Matrix account for messages sent from Android Messages app
   * [x] E2EE in Matrix rooms
diff --git a/mautrix_amp/portal.py b/mautrix_amp/portal.py
index b08f87ba4a14193ca96c30d500c44ec2bc1f844f..ad8a2bedc8c1d8f559cffbf87eee3256501d070a 100644
--- a/mautrix_amp/portal.py
+++ b/mautrix_amp/portal.py
@@ -25,11 +25,10 @@ import random
 import magic
 
 from mautrix.appservice import AppService, IntentAPI
-from mautrix.bridge import BasePortal, NotificationDisabler
+from mautrix.bridge import BasePortal
 from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType,
-                           TextMessageEventContent, MediaMessageEventContent,
-                           ContentURI, EncryptedFile)
-from mautrix.util.simple_lock import SimpleLock
+                           TextMessageEventContent, MediaMessageEventContent, ContentURI,
+                           EncryptedFile, ImageInfo, VideoInfo, FileInfo)
 
 from .db import Portal as DBPortal, Message as DBMessage
 from .config import Config
@@ -165,7 +164,7 @@ class Portal(DBPortal, BasePortal):
             return
 
         event_id = None
-        if evt.image:
+        if evt.media:
             content = await self._handle_remote_photo(source, intent, evt)
             if content:
                 event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
@@ -180,8 +179,22 @@ class Portal(DBPortal, BasePortal):
 
     async def _handle_remote_photo(self, source: 'u.User', intent: IntentAPI, message: Message
                                    ) -> Optional[MediaMessageEventContent]:
-        # TODO
-        pass
+        data = await source.client.download(message.media.guid, message.media.key)
+        reuploaded = await self._reupload_remote_media(data, intent)
+        if reuploaded.mime_type.startswith("image/"):
+            info = ImageInfo(width=message.media.width, height=message.media.height)
+            msgtype = MessageType.IMAGE
+        elif reuploaded.mime_type.startswith("video/"):
+            info = VideoInfo(width=message.media.width, height=message.media.height)
+            msgtype = MessageType.VIDEO
+        else:
+            info = FileInfo()
+            msgtype = MessageType.FILE
+        info.mimetype = reuploaded.mime_type
+        info.size = reuploaded.size
+        return MediaMessageEventContent(url=reuploaded.mxc, file=reuploaded.decryption_info,
+                                        body=message.media.filename or reuploaded.file_name,
+                                        info=info, msgtype=msgtype)
 
     async def _reupload_remote_media(self, data: bytes, intent: IntentAPI) -> ReuploadedMediaInfo:
         upload_mime_type = mime_type = magic.from_buffer(data, mime=True)
diff --git a/mautrix_amp/rpc/client.py b/mautrix_amp/rpc/client.py
index a2261f155992788944d1d3ea80bf6c2ca637723a..d56c2516bfcbff266f78036a4d768b1df461bcea 100644
--- a/mautrix_amp/rpc/client.py
+++ b/mautrix_amp/rpc/client.py
@@ -13,9 +13,10 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import AsyncGenerator, TypedDict, List, Dict, Callable, Awaitable, Any
+from typing import AsyncGenerator, TypedDict, Dict, Callable, Awaitable, Any
 from collections import deque
 import asyncio
+import json
 
 from .rpc import RPCClient
 from .types import ChatInfo, Message, StartStatus
@@ -49,6 +50,15 @@ class Client(RPCClient):
         await self.request("send", chat_id=chat_id, sender_id=sender_id, temp_id=temp_id,
                            text=text)
 
+    async def download(self, guid: str, key: str) -> bytes:
+        r, w = await self._open_connection()
+        w.write(json.dumps({"command": "download", "id": 1, "user_id": self.user_id,
+                            "guid": guid, "key": key}).encode("utf-8"))
+        w.write(b"\n")
+        data = await r.read()
+        w.close()
+        return data
+
     def on_message(self, func: Callable[[Message], Awaitable[None]]) -> None:
         async def wrapper(data: Dict[str, Any]) -> None:
             await func(Message.deserialize(data["message"]))
diff --git a/mautrix_amp/rpc/rpc.py b/mautrix_amp/rpc/rpc.py
index 96636f733bf2636899405955220255cd91cadaa1..40b08f3a080b72b311d8a36995de5ed09d0ca176 100644
--- a/mautrix_amp/rpc/rpc.py
+++ b/mautrix_amp/rpc/rpc.py
@@ -13,7 +13,7 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Dict, Any, Callable, Awaitable, List, Optional
+from typing import Dict, Any, Callable, Awaitable, List, Optional, Tuple
 import logging
 import asyncio
 import json
@@ -51,19 +51,20 @@ class RPCClient:
         self._writer = None
         self._reader = None
 
-    async def connect(self) -> None:
-        if self._writer is not None:
-            return
-
+    async def _open_connection(self) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]:
         if self.config["puppeteer.connection.type"] == "unix":
-            r, w = await asyncio.open_unix_connection(self.config["puppeteer.connection.path"])
+            return await asyncio.open_unix_connection(self.config["puppeteer.connection.path"])
         elif self.config["puppeteer.connection.type"] == "tcp":
-            r, w = await asyncio.open_connection(self.config["puppeteer.connection.host"],
+            return await asyncio.open_connection(self.config["puppeteer.connection.host"],
                                                  self.config["puppeteer.connection.port"])
         else:
             raise RuntimeError("invalid puppeteer connection type")
-        self._reader = r
-        self._writer = w
+
+    async def connect(self) -> None:
+        if self._writer is not None:
+            return
+
+        self._reader, self._writer = await self._open_connection()
         self.loop.create_task(self._try_read_loop())
         await self.request("register", user_id=self.user_id)
 
diff --git a/mautrix_amp/rpc/types.py b/mautrix_amp/rpc/types.py
index bd56e13f6c5f1dc4396ef1abf8d61af07dd6d8f6..2b2aba6873bf860948aeb727a8af4b195f39d259 100644
--- a/mautrix_amp/rpc/types.py
+++ b/mautrix_amp/rpc/types.py
@@ -42,6 +42,16 @@ class ChatInfo(SerializableAttrs['ChatInfo']):
     readonly: bool
 
 
+@dataclass
+class MediaInfo(SerializableAttrs['MediaInfo']):
+    guid: str
+    key: str
+    filename: Optional[str] = None
+    size: Optional[int] = None
+    width: Optional[int] = None
+    height: Optional[int] = None
+
+
 @dataclass
 class Message(SerializableAttrs['Message']):
     id: int
@@ -53,7 +63,7 @@ class Message(SerializableAttrs['Message']):
     temp_id: Optional[str] = None
     subject: Optional[str] = None
     text: Optional[str] = None
-    image: Optional[str] = None
+    media: Optional[MediaInfo] = None
 
 
 @dataclass
diff --git a/puppet/dh-messages-api b/puppet/dh-messages-api
index f83d735f684718e50e1e9fbdce0abe4ef2345365..082f349c400c03b524b76866ed485ef6afcce9ca 160000
--- a/puppet/dh-messages-api
+++ b/puppet/dh-messages-api
@@ -1 +1 @@
-Subproject commit f83d735f684718e50e1e9fbdce0abe4ef2345365
+Subproject commit 082f349c400c03b524b76866ed485ef6afcce9ca
diff --git a/puppet/src/client.js b/puppet/src/client.js
index 085ca2d8555dbb62fba9ae0e84e648eaee258aca..569f48b9baa4d7c79658354fa73e20b90776029a 100644
--- a/puppet/src/client.js
+++ b/puppet/src/client.js
@@ -120,6 +120,14 @@ export default class Client {
 			text: item.MsgText,
 			is_outgoing: item.MsgSentByUser,
 			is_echo: item.SentByClient,
+			media: item.ImageData ? {
+				guid: item.ImageData[1],
+				filename: item.ImageData[3],
+				size: item.ImageData[4],
+				width: item.ImageData[5]?.[0],
+				height: item.ImageData[5]?.[1],
+				key: item.ImageData[10],
+			} : undefined,
 		})).sort((a, b) => a.id - b.id)
 		this.log("Sending message list with IDs", messages.map(msg => msg.id), "to client")
 		await this._write({
@@ -256,6 +264,22 @@ export default class Client {
 		await this.client.Connect()
 	}
 
+	handleDownload = async req => {
+		this.userID = req.user_id
+		this.stopped = true
+		const client = this.manager.msgClients.get(this.userID)
+		if (!client) {
+			this.log("Couldn't find msgClient for", this.userID)
+		} else {
+			this.log("Downloading file", req.guid)
+			const data = await client.DownloadFile(req.guid, req.key)
+			this.log("Sending file", req.guid, "to client")
+			await promisify(cb => this.socket.write(Buffer.from(data, "base64"), cb))
+		}
+		this.log("Disconnecting file download connection")
+		await promisify(cb => this.socket.end(cb))
+	}
+
 	async handleLine(line) {
 		if (this.stopped) {
 			this.log("Ignoring line, client is stopped")
@@ -278,6 +302,10 @@ export default class Client {
 		}
 		this.log("Received request", req.id, "with command", req.command)
 		this.maxCommandID = req.id
+		if (!this.userID && req.command === "download") {
+			await this.handleDownload(req)
+			return
+		}
 		let handler
 		if (!this.userID) {
 			if (req.command !== "register") {