diff --git a/app/build.gradle b/app/build.gradle
index d7f1aaeb21f3d69167971cd7000aa8ba585726e5..475f2d3c6ab5be325104a18a0d1c923b399a4b35 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -39,4 +39,5 @@ dependencies {
     implementation 'com.squareup.okhttp3:okhttp:4.1.0'
     implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
     implementation 'androidx.work:work-runtime-ktx:2.2.0'
+    implementation 'org.apache.commons:commons-compress:1.18'
 }
diff --git a/app/src/main/java/de/ccc/events/badge/card10/hatchery/AppDetailFragment.kt b/app/src/main/java/de/ccc/events/badge/card10/hatchery/AppDetailFragment.kt
index 5e5e876f85443048c7c8e116e4535c94d0f1f15e..f1926c94d04ef244df6c07ecb89720976cdedf10 100644
--- a/app/src/main/java/de/ccc/events/badge/card10/hatchery/AppDetailFragment.kt
+++ b/app/src/main/java/de/ccc/events/badge/card10/hatchery/AppDetailFragment.kt
@@ -22,14 +22,22 @@
 
 package de.ccc.events.badge.card10.hatchery
 
+import android.os.AsyncTask
 import android.os.Bundle
+import android.util.Log
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
+import androidx.appcompat.app.AlertDialog
 import androidx.fragment.app.Fragment
 import de.ccc.events.badge.card10.R
+import de.ccc.events.badge.card10.common.LoadingDialog
 import kotlinx.android.synthetic.main.app_detail_fragment.*
-import java.lang.IllegalStateException
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
+import java.io.File
+
+private const val TAG = "AppDetailFragment"
 
 class AppDetailFragment : Fragment() {
 
@@ -51,5 +59,93 @@ class AppDetailFragment : Fragment() {
         label_content_size.text = getString(R.string.app_detail_content_size, app.size_of_content)
 
         label_description.text = app.description
+
+        button_download.setOnClickListener {
+            val ctx = activity ?: throw java.lang.IllegalStateException()
+
+            val loadingDialog = LoadingDialog()
+            loadingDialog.show(fragmentManager, "loading")
+
+            val errorDialog =
+                AlertDialog.Builder(ctx).setMessage(R.string.hatchery_error_generic)
+                    .setPositiveButton(
+                        R.string.dialog_action_ok
+                    ) { dialog, _ -> dialog.dismiss() }
+                    .create()
+
+            ReleaseDownload(app, ctx.cacheDir, loadingDialog, errorDialog).execute()
+        }
+    }
+
+    private class ReleaseDownload(
+        private val app: App,
+        private val cacheDir: File,
+        private val loadingDialog: LoadingDialog,
+        private val errorDialog: AlertDialog
+    ) : AsyncTask<Void, Void, List<String>?>() {
+
+        override fun doInBackground(vararg p0: Void?): List<String>? {
+            return try {
+                cacheDir.deleteRecursively()
+                cacheDir.mkdir()
+                val appDir = File(cacheDir.absolutePath + "/apps").mkdirs()
+
+                val inputStream = HatcheryClient().openDownloadStream(app)
+                val file = File.createTempFile(app.slug, ".tar.gz", cacheDir)
+                val outputStream = file.outputStream()
+
+                inputStream.copyTo(outputStream)
+
+                val appFiles = mutableListOf<String>()
+                val tarStream = TarArchiveInputStream(GzipCompressorInputStream(file.inputStream()))
+                while (true) {
+                    val entry = tarStream.nextTarEntry ?: break
+                    if (entry.isDirectory) {
+                        continue
+                    }
+
+                    // TODO: A bit hacky. Maybe there is a better way?
+                    val targetFile = File(cacheDir, "apps/${entry.name}")
+                    targetFile.parentFile?.mkdirs()
+                    targetFile.createNewFile()
+                    Log.d(TAG, "Extracting ${entry.name} to ${targetFile.absolutePath}")
+                    tarStream.copyTo(targetFile.outputStream())
+                    appFiles.add("apps/${entry.name}")
+                }
+
+                val launcher = createLauncher(app.slug, cacheDir)
+                appFiles.add(launcher)
+
+                appFiles
+            } catch (e: Exception) {
+                null
+            }
+        }
+
+        override fun onPostExecute(result: List<String>?) {
+            if (result == null) {
+                loadingDialog.dismiss()
+                errorDialog.show()
+                return
+            }
+
+            loadingDialog.dismiss()
+        }
+
+        fun createLauncher(slug: String, cacheDir: File): String {
+            val fileName = "$slug.py"
+            val file = File(cacheDir, fileName)
+            file.createNewFile()
+
+            val src = """
+                # Launcher script for $slug
+                import os
+                os.exec("apps/$slug/__init__.py")
+            """.trimIndent()
+
+            file.writeText(src)
+
+            return fileName
+        }
     }
 }
diff --git a/app/src/main/java/de/ccc/events/badge/card10/hatchery/HatcheryClient.kt b/app/src/main/java/de/ccc/events/badge/card10/hatchery/HatcheryClient.kt
index 456e0731c44eb32e8b441fc5cea0ca146cde65ca..4dad8e53f7663a4ff6da9c32786b0efb82ad4015 100644
--- a/app/src/main/java/de/ccc/events/badge/card10/hatchery/HatcheryClient.kt
+++ b/app/src/main/java/de/ccc/events/badge/card10/hatchery/HatcheryClient.kt
@@ -29,14 +29,18 @@ import okhttp3.Request
 import okhttp3.Response
 import org.json.JSONArray
 import org.json.JSONException
+import org.json.JSONObject
+import java.io.InputStream
 
 private const val TAG = "HatcheryClient"
 
 class HatcheryClient {
     fun getAppList(): List<App> {
         val client = OkHttpClient()
+
+        // TODO: Filter by category
         val request = Request.Builder()
-            .url(HATCHERY_BASE_URL + "/eggs/list/json")
+            .url("$HATCHERY_BASE_URL/eggs/list/json")
             .build()
 
         val response: Response
@@ -79,4 +83,57 @@ class HatcheryClient {
 
         return resultList
     }
+
+    fun getReleaseUrl(app: App): String {
+        val client = OkHttpClient()
+        val request = Request.Builder()
+            .url("$HATCHERY_BASE_URL/eggs/get/${app.slug}/json")
+            .build()
+
+        val response: Response
+        try {
+            response = client.newCall(request).execute()
+        } catch (e: Exception) {
+            throw HatcheryClientException(0)
+        }
+
+        if (response.code != 200) {
+            throw HatcheryClientException(response.code)
+        }
+
+        val body = response.body?.string() ?: ""
+
+        try {
+            val responseJson = JSONObject(body)
+            val version = responseJson.getJSONObject("info").getString("version")
+            return responseJson.getJSONObject("releases")
+                .getJSONArray(version).getJSONObject(0).getString("url")
+
+        } catch (e: JSONException) {
+            Log.e(TAG, "Error parsing JSON: ${e.message}")
+            throw HatcheryClientException(0)
+        }
+    }
+
+    fun openDownloadStream(app: App): InputStream {
+        val releaseUrl = getReleaseUrl(app)
+
+        val client = OkHttpClient()
+        val request = Request.Builder()
+            .url(releaseUrl)
+            .build()
+
+        val response: Response
+        try {
+            response = client.newCall(request).execute()
+        } catch (e: Exception) {
+            throw HatcheryClientException(0)
+        }
+
+        if (response.code != 200) {
+            throw HatcheryClientException(response.code)
+        }
+
+        return response.body?.byteStream() ?: throw HatcheryClientException(0)
+    }
 }
diff --git a/app/src/main/res/layout/app_detail_fragment.xml b/app/src/main/res/layout/app_detail_fragment.xml
index de0afaf56976b8e6d9053dae0c584857ec6f39f6..1e9b0a6a1e4ce09b40e8c83faf5a7b39d18da614 100644
--- a/app/src/main/res/layout/app_detail_fragment.xml
+++ b/app/src/main/res/layout/app_detail_fragment.xml
@@ -35,8 +35,7 @@
                 app:layout_constraintRight_toRightOf="parent"
                 app:layout_constraintTop_toTopOf="parent"
                 android:id="@+id/button_download"
-                android:text="@string/app_detail_button_download"
-                android:enabled="false"/>
+                android:text="@string/app_detail_button_download"/>
 
     </androidx.constraintlayout.widget.ConstraintLayout>
 
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 084ef4e1dcbf3ffa8ee9f1be01f4fa50e2c35670..ac827b1a52fe6f327cbc69176a6ce0cf2e1ec7fd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -16,6 +16,8 @@
     <string name="file_transfer_label_selected_file">Selected file:</string>
 
     <string name="loading_dialog_loading">Loading</string>
+    <string name="dialog_action_ok">OK</string>
+    <string name="dialog_action_cancel">Cancel</string>
 
     <string name="hatchery_error_generic">Something went wrong</string>