Most of the API is implemented
authorChristian Weiske <cweiske@cweiske.de>
Wed, 4 May 2022 20:02:49 +0000 (22:02 +0200)
committerChristian Weiske <cweiske@cweiske.de>
Wed, 4 May 2022 20:03:00 +0000 (22:03 +0200)
.gitignore
README.rst [new file with mode: 0644]
bin/copy-from-stouyapi.sh [new file with mode: 0755]
bin/test-api.sh [new file with mode: 0755]
src/main/assets/favicon.ico [new file with mode: 0644]
src/main/assets/index.htm [new file with mode: 0644]
src/main/assets/stouyapi-www/.gitkeep [new file with mode: 0644]
src/main/java/de/cweiske/ouya/louyapi/HttpServer.java
src/main/java/de/cweiske/ouya/louyapi/HttpService.java

index aa724b77071afcbd9bb398053e05adaf7ac9405a..e457bf33ca72c656d39814758598c836ae967e79 100644 (file)
@@ -1,3 +1,5 @@
+/src/main/assets/stouyapi-www/
+
 *.iml
 .gradle
 /local.properties
@@ -12,4 +14,4 @@
 /captures
 .externalNativeBuild
 .cxx
-local.properties
+local.properties
\ No newline at end of file
diff --git a/README.rst b/README.rst
new file mode 100644 (file)
index 0000000..324c133
--- /dev/null
@@ -0,0 +1,14 @@
+===================================
+louyapi - Local OUYA API server app
+===================================
+
+An application for the OUYA that run an OUYA API server.
+With it, the OUYA brings its own API server it can talk to.
+
+
+
+
+Helpful information for implementation
+======================================
+- https://github.com/NanoHttpd/nanohttpd/tree/nanohttpd-project-2.3.1
+- https://fabcirablog.weebly.com/blog/creating-a-never-ending-background-service-in-android
diff --git a/bin/copy-from-stouyapi.sh b/bin/copy-from-stouyapi.sh
new file mode 100755 (executable)
index 0000000..a0075ca
--- /dev/null
@@ -0,0 +1,16 @@
+#!/bin/sh
+# copy all generated static files from stouyapi to here
+stouyapidir=/home/cweiske/dev/ouya/stouyapi
+targetdir=./src/main/assets/stouyapi-www
+
+#cd to louyapi directory
+SCRIPT=$(readlink -f "$0")
+SCRIPTPATH=$(dirname "$SCRIPT")
+cd "$SCRIPTPATH/../$targetdir"
+
+rm -r *
+cp -a "$stouyapidir/www/agreements" .
+cp -a "$stouyapidir/www/api" .
+cp -a "$stouyapidir/www/favicon.ico" .
+cp -a "$stouyapidir/www/gen-qr" .
+cp -a "$stouyapidir/www/updates-ouya_1_1.json" .
diff --git a/bin/test-api.sh b/bin/test-api.sh
new file mode 100755 (executable)
index 0000000..23bb3a8
--- /dev/null
@@ -0,0 +1,25 @@
+#!/bin/sh
+set -ex
+host=http://localhost:8080
+
+curl --fail -i "$host/api/v1/status"
+curl --fail -i "$host/api/firmware_builds"
+curl --fail -I "$host/updates-ouya_1_1.json"
+curl --fail -i "$host/api/v1/developers/fc215877-e840-4a4f-b07a-b5696ac1b7ff/products"
+curl --fail -I "$host/api/v1/details?app=org.blockinger.game"
+curl --fail -i "$host/api/v1/details?app=does.not.exist"
+curl --fail -i "$host/api/v1/apps/de.eiswuxe.blookid2"
+curl --fail -i "$host/api/v1/apps/780688a9-95ee-429a-8755-69a8d0c88fe0/download"
+curl --fail -i "$host/api/v1/developers/5a3fbb4d-852b-4af4-becc-324dce6a3b42/products/"
+curl --fail -i "$host/api/v1/developers/5a3fbb4d-852b-4af4-becc-324dce6a3b42/products/?only=blookid2_full"
+curl --fail -I "$host/api/v1/discover"
+curl --fail -I "$host/api/v1/discover/home"
+curl --fail -I "$host/api/v1/discover/tutorials"
+curl -I "$host/api/v1/gamers"
+curl --fail -I "$host/api/v1/gamers/me"
+curl --fail -I "$host/api/v1/games/de.eiswuxe.blookid2/purchases"
+curl --fail -I "$host/api/v1/games/does.not.exist/purchases"
+curl --fail -I "$host/api/v1/queued_downloads"
+curl --fail -I "$host/api/v1/search?q=1"
+
+echo "done"
diff --git a/src/main/assets/favicon.ico b/src/main/assets/favicon.ico
new file mode 100644 (file)
index 0000000..2cf7f6f
Binary files /dev/null and b/src/main/assets/favicon.ico differ
diff --git a/src/main/assets/index.htm b/src/main/assets/index.htm
new file mode 100644 (file)
index 0000000..a0f2eb7
--- /dev/null
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+ <head>
+  <meta charset="utf-8"/>
+  <title>louyapi</title>
+ </head>
+ <body>
+  <h1>louyapi - local OUYA API server</h1>
+  <p>
+   The OUYA gaming console needs an API server to talk to,
+   and this is an API server that runs on the OUYA itself.
+  </p>
+  <p>
+   Your OUYA still needs an internet connection to fetch
+   the game images and .apk game files when browsing the
+   store and downloading games.
+  </p>
+
+
+  <h2>About</h2>
+  <p>
+   This OUYA API server was written by
+   <a href="http://cweiske.de/">Christian Weiske</a>.
+   The louyapi code is open source:
+   <a href="https://git.cweiske.de/louyapi.git">git.cweiske.de/louyapi.git</a>
+   (<a href="https://github.com/cweiske/louyapi/">github mirror</a>).
+   It was built with the help of the
+   <a href="http://cweiske.de/ouya-store-api-docs.htm">OUYA store API documentation</a>,
+   uses the
+   <a href="https://github.com/ouya-saviors/ouya-game-data">OUYA game data repository</a>
+   and contains files generated by the
+   <a href="https://git.cweiske.de/stouyapi.git">stouyapi server</a>
+   (<a href="https://github.com/cweiske/stouyapi/">github mirror</a>).
+  </p>
+ </body>
+</html>
diff --git a/src/main/assets/stouyapi-www/.gitkeep b/src/main/assets/stouyapi-www/.gitkeep
new file mode 100644 (file)
index 0000000..e69de29
index 84e9cddfcc847f532257f3657acf52228e71ac29..2e5779a8b1b8bd40da9a4dafbe4c84b2ea92aa27 100644 (file)
 package de.cweiske.ouya.louyapi;
 
+import static de.cweiske.ouya.louyapi.HttpService.TAG;
+
+import android.content.res.AssetManager;
+import android.util.Log;
+
+import java.io.IOException;
+import java.io.InputStream;
+
 import fi.iki.elonen.NanoHTTPD;
 
 public class HttpServer extends NanoHTTPD {
 
-    public HttpServer(int port) {
+    private final AssetManager assetManager;
+    protected String prefix = "stouyapi-www";
+
+    public HttpServer(int port, AssetManager assetManager) {
         super(port);
+        this.assetManager = assetManager;
     }
 
-    @Override
+    /**
+     * This is basically a re-implementation of the stouyapi .htaccess file
+     *
+     * @param session The HTTP session
+     * @return HTTP response
+     */
     public Response serve(IHTTPSession session) {
-        return newFixedLengthResponse(Response.Status.OK, "text/plain", "hello");
+        String path = session.getUri();
+        InputStream content;
+
+        if (path.equals("/api/v1/status") || path.equals("/generate_204")) {
+            //usage: check if internet connection is working
+            return newFixedLengthResponse(Response.Status.NO_CONTENT, null, "");
+
+        } else if (path.equals("/api/v1/details") && session.getParameters().containsKey("app")) {
+            //usage: detail page for installed games
+            if (null != (content = loadFileContent("/api/v1/details-data/" + session.getParameters().get("app").get(0) + ".json"))) {
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            } else {
+                content = loadFileContent("/api/v1/details-dummy/error-unknown-app-details.json");
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+
+        } else if (path.startsWith("/api/v1/apps/") && path.endsWith("/download")) {
+            String appid = path.substring(13, path.length() - 9);
+            if (null != (content = loadFileContent("/api/v1/apps/" + appid + "-download.json"))) {
+
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+
+        } else if (path.startsWith("/api/v1/apps/")) {
+            String appid = path.substring(13);
+            if (null != (content = loadFileContent("/api/v1/apps/" + appid + ".json"))) {
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+
+        } else if (path.startsWith("/api/v1/developers") && path.endsWith("/products/") && session.getParameters().containsKey("only")) {
+            //usage: product details for a single product
+            if (null != (content = loadFileContent(path + session.getParameters().get("only").get(0) + ".json"))) {
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+
+        } else if (path.equals("/api/v1/discover")) {
+            //usage: main store page
+            if (null != (content = loadFileContent("/api/v1/discover-data/discover.json"))) {
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+
+        } else if (path.startsWith("/api/v1/discover/")) {
+            //usage: store category
+            if (null != (content = loadFileContent("/api/v1/discover-data/" + path.substring(17) + ".json"))) {
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+
+        } else if (path.equals("/api/v1/gamers")) {
+            //usage: register a user
+            if (null != (content = loadFileContent("/api/v1/gamers/register-error.json"))) {
+                return newFixedLengthResponse(Response.Status.BAD_REQUEST, "application/json", content);
+            }
+
+        } else if (path.equals("/api/v1/gamers/me")) {
+            //usage: fetch user data
+            if (null != (content = loadFileContent("/api/v1/gamers/me.json"))) {
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+
+        } else if (path.equals("/api/v1/search") && session.getParameters().containsKey("q")) {
+            //usage: search for games
+            String query = session.getParameters().get("q").get(0);
+            if (null != (content = loadFileContent("/api/v1/search-data/" + query.charAt(0) + ".json"))) {
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+
+        } else if (!path.endsWith("/") && null != (content = loadFileContent(path))) {
+            //try if the path exists in the assets/stouyapi-www/ dir
+            return newFixedLengthResponse(Response.Status.OK, mimeTypeFromPath(path), content);
+
+        } else if (null != (content = loadFileContent(path.replaceAll("/$", "") + "/index.htm"))) {
+            //usage: /api/v1/developers/*/products
+            return newFixedLengthResponse(Response.Status.OK, "text/html", content);
+
+        } else if (!path.endsWith("/") && null != (content = loadAssetsFileContent(path))) {
+            //try if the path exists in the assets/ dir (without stouyapi-www)
+            return newFixedLengthResponse(Response.Status.OK, mimeTypeFromPath(path), content);
+
+        } else if (null != (content = loadAssetsFileContent(path.replaceAll("/$", "") + "/index.htm"))) {
+            //try if the path + index.htm exists in the assets/ dir (without stouyapi-www)
+            return newFixedLengthResponse(Response.Status.OK, "text/html", content);
+
+        } else if (path.startsWith("/api/v1/games/") && path.endsWith("/purchases")) {
+            //usage: purchases for non-existing game
+            if (null != (content = loadFileContent("/api/v1/games/purchases-empty.json"))) {
+                return newFixedLengthResponse(Response.Status.OK, "application/json", content);
+            }
+        }
+
+        return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "File not found");
+    }
+
+    /**
+     * Automatically determine the length of the input stream
+     */
+    protected Response newFixedLengthResponse(Response.IStatus status, String mimeType, InputStream data) {
+        int length = 0;
+        try {
+            length = streamLength(data);
+            data.reset();
+        } catch (IOException e) {
+            Log.e(TAG, e.getMessage());
+            return newFixedLengthResponse(
+                Response.Status.INTERNAL_ERROR, "text/plain",
+                "Error: " + e.getMessage()
+            );
+        }
+        return newFixedLengthResponse(status, mimeType, data, length);
+    }
+
+    protected InputStream loadFileContent(String path)
+    {
+        return loadAssetsFileContent(prefix + path);
+    }
+
+    protected InputStream loadAssetsFileContent(String path)
+    {
+        path = path.replaceAll("^/", "");
+        try {
+            Log.d(TAG, "loadFileContent: " + path);
+            return assetManager.open(path, AssetManager.ACCESS_BUFFER);
+        } catch (IOException e) {
+            //file does not exist
+            Log.d(TAG, "loadFileContent: fail");
+            return null;
+        }
+    }
+
+    protected static int streamLength(InputStream inputStream) throws IOException {
+        byte[] buffer = new byte[4096];
+        int chunkBytesRead = 0;
+        int length = 0;
+        while((chunkBytesRead = inputStream.read(buffer)) != -1) {
+            length += chunkBytesRead;
+        }
+        return length;
+    }
+
+    protected String mimeTypeFromPath(String path) {
+        if (path.endsWith(".htm")) {
+            return "text/html";
+        } else if (path.endsWith(".ico")) {
+            return "image/vnd.microsoft.icon";
+        } else if (path.endsWith(".jpg")) {
+            return "image/jpeg";
+        } else if (path.endsWith(".png")) {
+            return "image/png";
+        }
+        return "text/plain";
+    }
+
+    protected boolean isBinary(String path) {
+        return path.endsWith(".ico")
+            || path.endsWith(".jpg")
+            || path.endsWith(".png");
     }
 }
index f5cc5346434bba5dc45da072f71b6e03527c8954..ed122ec8b59d098f76ded9e396ea5835c2dc2b4c 100644 (file)
@@ -19,7 +19,7 @@ public class HttpService extends Service {
         Log.i("service", "start service");
         super.onCreate();
 
-        server = new HttpServer(8080);
+        server = new HttpServer(8080, getAssets());
         try {
             server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
         } catch (IOException ioe) {