Introduction
My girlfriends birthday is coming up, and I wanted to make something cool as a present. I previously made a little text display out of an arduino with an LCD character display and a cigar box, and I wanted to make something similar but better this time. Given I've now got a 3D printer, a homelab network and more programming skills, I figured I would make something that's a little bigger and better. The idea is to make a device out of an old phone that will display pictures with text over it, something like an electronic photo frame, but with a twist. I want some pieces of text to be displayed on it every now and then, and I want to add functionality that I can add pieces of text or pictures to it after I've built it.
Hardware
I decided to make this out of an old Nexus 5 I had lying around. I already rooted it and configured it to automatically turn on when power is applied. Apart from that I needed to 3D print an enclosure and make sure it could run without a battery.
Running it without a battery
Because I don't want the battery to start swelling up after some time, I needed to make sure it could run without a battery. For that, I used this post and this post from the same guy. He goes into great detail about the battery and what is needed to make the phone run without a battery.
I first removed the battery and the battery connector PCB with help from this video. After that I was left with the connector PCB which I soldered some wires to


Electronics
I couldn't directly attaching the terminals to the output of a wall charger, because the battery gives a voltage of 3.8V - 4.3V, and I want to mimic the battery as much as possible. To fix this, I used the LM2596 DC-DC step down converter (datasheet) to convert the 5V from a phone charger to the 4.2V I was going to use. When I connected the module directly to the phone and to a 5V charger, I noticed the voltage kept dropping to about 3.6V with spikes of 3.3V, which would turn off the phone. Apparently, the LM2596 has a dropout voltage of about 0.9V, so it couldn't handle the load from the phone all that well. I tried to fix this with a 470uF capacitor on the input and output rails, but that still didn't work. The module needed a bit more headroom than just the 5V to be able to supply enough load, so I switched the 5V phone charger to a 12V power supply adapter.
I also needed to supply the charging port with 5V, because I had previously rooted the phone and made it turn on when a charger is connected. And I wanted it to turn on when the adapter would be plugged in. To do this, I used a second LM2596 module that I set to output 5.1V. I also added 470uF capacitors to the input and output rails of this to keep the voltage stable. On top of that, I added an 1N4007 diode (also here) between the LM2596 output and capacitor to improve the functionality a bit. To attach the output of the module to the I ordered some USB micro-B connectors. The end result worked perfectly!
.jpg)
Enclosure
After the electronics it was time to create an enclosure. I wanted to make it just a box with a slanted front that the screen would sit in. I feared this part the most because it's what I have the least experience in. I can model some things in FreeCAD, but this would be quite a complex model consisting of multiple parts. However, after some research and watching youtube videos I got some more confidence. I made some notes about the things I learned to be able to look them up easily.
After a few days of modeling, I created an enclosure with a lid that will hold the phone. The phone will clip into the enclosure and I will use M2 heatset inserts with M2 screws to screw on the lid. All files are all available for download:
The finished print with the phone inside looks like this:




Software
Android app
Next to the website I would need to write, I also needed a way for the website to automatically open when the phone boots. For this I used a combination of an Android app I wrote and Fullscreen Browser. The app only launches the website using the default browser on android, and the Fullscreen Browser is set as default, to make the viewing fullscreen.
The app does a few things:
- Set the view to edge-to-edge mode (not really necessary as we open a new app)
- Start a service that keeps the screen awake
- Start an intent to launch the browser window with the specified URL.
- Listen for a BootCompleted event to start the app automatically when the phone boots.
public class MainActivity extends AppCompatActivity {
public static String TAG = MainActivity.class.getName();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this); // edge to edge mode for fullscreen experience
setContentView(R.layout.activity_main);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); // keep the screen on
// Set the status bar and navigation bar to be transparent, also to get edge to edge
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
Toast.makeText(this, "Hoi :)", Toast.LENGTH_SHORT).show();
// start service for extra wake lock
startService(new Intent(this, RunningService.class));
Log.i(TAG, "test after starting service");
Log.i(TAG, "Starting browser intent!");
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(SystemData.URL));
startActivity(browserIntent);
}
@Override
protected void onDestroy() {
SystemData data = SystemData.INSTANCE;
data.setOpenedBrowser(false);
super.onDestroy();
}
}
The RunningService
to keep the screen awake looks like this:
public class RunningService extends Service {
public static String TAG = RunningService.class.getName();
private PowerManager.WakeLock wakeLock;
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "Eyoo this service just got started whoop whoop");
PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE);
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SiennaMessagesLauncher:WakeLockTag");
if (!wakeLock.isHeld()) {
wakeLock.acquire();
}
return super.onStartCommand(intent, flags, startId);
}
@Override
public void onDestroy() {
Log.i(TAG, "destroying this bitch");
wakeLock.release();
super.onDestroy();
}
}
I also added a SystemData
singleton (using an enum) to keep track of if the website was already opened or not, so we don't open it twice after getting the BootCompleted
event.
public enum SystemData {
INSTANCE;
boolean openedBrowser;
static String URL = "https://url-here";
public void setOpenedBrowser(boolean openedBrowser) {
this.openedBrowser = openedBrowser;
}
public boolean hasOpenedBrowser() {
return openedBrowser;
}
}
Speaking of the BootCompleted
event, I added a BootCompletedReceiver
that handles the event and also launches an intent to start the browser and open the URL:
public class BootCompletedReceiver extends BroadcastReceiver {
public static String TAG = BootCompletedReceiver.class.getName();
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) {
Log.e(TAG, "Received null intent!");
return;
}
if (intent.getAction() == null) {
Log.e(TAG, "Action of received intent is null!");
return;
}
if(intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
Log.i(TAG, "DAAAMN BOY THAT BITCH BOOTED");
Toast.makeText(context, "I love you <3", Toast.LENGTH_SHORT).show();
SystemData data = SystemData.INSTANCE;
if (!data.hasOpenedBrowser())
{
data.setOpenedBrowser(true);
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(SystemData.URL));
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(browserIntent);
}
}
}
}
To make sure the receiver gets the event however, we can add an intent-filter
to our AndroidManifest.xml
file. This makes sure only the ACTION_BOOT_COMPLETED
event is sent to the receiver:
In the app manifest file, the permissions are also set for the wake lock (android.permission.WAKE_LOCK
) and the boot completed event (android.permission.RECEIVE_BOOT_COMPLETED
)
Website
The software will consist of a frontend and a backend. The frontend will show all kinds of pictures with messages, kind of like a digital photo frame mixed with an old-school guest book. The backend will be a node webserver with a mysql database. I set up a new LXC container in Proxmox to host all this.
For both the backend and the frontend I used Node.JS, because I am familiar with it (this website runs on it)
and I like how it works. I made the normal /
endpoint page send a request for a new random image to
the backend every minute, and added a /upload
endpoint with a form that allows people to upload
their message, picture and name. To make it somewhat real-time responsive, the page also sends a request to get
the latest added message every 2 seconds, so when a new message is uploaded it is immediately shown.
The main endpoint shows the picture by scaling it to 100% using the object-fit: cover
CSS style, to make sure the whole image is always visible. It
also has animations for switching the image, and I added a button to show/hide the text in the top-left corner,
because some people wrote whole paragraphs. The html for the frontend is this:
And the code for the javascript is this:
let text = document.getElementById("text-overlay");
function showText(text) {
text.classList.remove("hide");
text.classList.add("show");
}
function hideText(text) {
text.classList.remove("show");
text.classList.add("hide");
}
function toggleText() {
if (text.classList.contains("hide")) {
showText(text);
console.log("showing");
} else {
hideText(text);
console.log("hiding");
}
}
const RANDOM_MESSAGES_TIMER_TIMEOUT_MS = 1000 * 60 * 1; // one minute
const ADDED_SINCE_TIMER_TIMEOUT_MS = 1000 * 2; // 2 seconds
const ADDED_SINCE_SECONDS = 2;
let used_ids = [];
window.onresize = () => { sizeImage(document.getElementById("message-image")); };
function rtrim(x) {
return x.replace(/\s+$/gm, '');
}
function check_added_since() {
var added_since = 3; // 3 seconds to be sure (we check every 2 seconds (ADDED_SINCE_TIMER_TIMEOUT_MS), but sending the request might take some time)
let url = "/api/messages/last-added?added_since=" + encodeURIComponent(added_since);
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
console.log(this.responseText);
var response = JSON.parse(this.responseText);
if (response["message"] == null) {
return;
}
console.log("Received brand new message");
// reset the timer for random messages
// we do this so it won't override it if for example we get a new message 1 second before the random timer triggers
clearInterval(random_message_timer);
random_message_timer = setInterval(get_random_message, RANDOM_MESSAGES_TIMER_TIMEOUT_MS);
process_new_message(response);
}
};
xhttp.open("GET", url, true);
xhttp.send();
}
function get_random_message() {
// create URL with the used ids list as json string
let url = "/api/messages?used_ids=" + encodeURIComponent(JSON.stringify(used_ids));
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
console.log(this.responseText);
process_new_message(JSON.parse(this.responseText));
}
};
xhttp.open("GET", url, true);
xhttp.send();
}
function process_new_message(response) {
const message = response["message"];
if (message != null) {
console.log("got valid response: " + response);
console.log("message in response: " + message);
}
if (response["last"]) {
// used up all ids, start with empty list
console.log("Looping around, clearing list");
used_ids = [];
} else if (message["id"] != null) {
used_ids.push(message["id"]);
}
var message_content = rtrim(message["message"]);
var message_text_element = document.getElementById("message-text");
if (message.length > 300)
{
message_text_element.style.fontSize = "8pt";
} else {
message_text_element.style.fontSize = "initial";
}
message_text_element.innerText = message_content;
var date_text = message["date"].split("T")[0];
var date_parts = date_text.split("-")
var actual_date = date_parts[2] + "-" + date_parts[1] + "-" + date_parts[0];
document.getElementById("author-date").innerText = message["author"] + ", " + actual_date;
var image = document.getElementById("message-image")
// if no image, put text in center
if (message["image_location"] == null) {
var textbox = document.getElementById("text-overlay");
textbox.style.margin = "auto";
textbox.style.bottom = "0";
textbox.style.textAlign = "center";
image.style.opacity = 0; // fade out current image
} else {
console.log("fading out image");
var textbox = document.getElementById("text-overlay");
textbox.style.textAlign = "left"
textbox.style.margin = "initial"
image.style.opacity = 0; // fade out current image
setTimeout(() => { // fade in the new image after it has been loaded. This runs after the image has faded out
image.src = message["image_location"] ? "/img/upload/" + message["image_location"] : "";
image.onload = function () {
console.log("Image loaded with dimensions:", image.naturalWidth, "x", image.naturalHeight);
console.log("Width:", window.innerWidth, "Height:", window.innerHeight);
console.log("Image loaded successfully");
console.log("fading in image");
image.style.opacity = 1; // fade in the new image
sizeImage(image);
};
}, 500); // Wait 500ms (matching the CSS transition time)
}
}
function sizeImage(image) {
let screenWidth = window.innerWidth;
let screenHeight = window.innerHeight;
let imgWidth = image.naturalWidth;
let imgHeight = image.naturalHeight;
// Determine scale factor to fit the image fully inside the screen
let scale = Math.min(screenWidth / imgWidth, screenHeight / imgHeight);
// Set the new width and height
let newWidth = imgWidth * scale;
let newHeight = imgHeight * scale;
image.style.width = newWidth + "px";
image.style.height = newHeight + "px";
// Center the image
image.style.position = "absolute";
image.style.left = (screenWidth - newWidth) / 2 + "px";
image.style.top = (screenHeight - newHeight) / 2 + "px";
}
get_random_message();
let random_message_timer = setInterval(get_random_message, RANDOM_MESSAGES_TIMER_TIMEOUT_MS);
let added_since_timer = setInterval(check_added_since, ADDED_SINCE_TIMER_TIMEOUT_MS);
And the code for the CSS is this:
@font-face {
font-family: 'OpenDyslexic';
src: url('OpenDyslexic-Regular.eot');
/* for old IE */
src: url('OpenDyslexic-Regular.eot?#iefix') format('embedded-opentype'),
url('OpenDyslexic-Regular.woff2') format('woff2'),
url('OpenDyslexic-Regular.woff') format('woff'),
url('OpenDyslexic-Regular.otf') format('opentype');
/* If supported */
font-weight: normal;
font-style: normal;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
/* Prevent scrolling */
overflow: hidden;
background-color: rgb(82, 82, 82);
}
h1 {
font-family: 'OpenDyslexic', 'Courier New';
}
#message-image {
margin: auto;
position: absolute;
/* width: 100%;
height: 100%; */
object-fit: cover;
/* Ensures the image fills the screen without distortion */
transition: opacity 0.5s ease-in-out;
opacity: 1;
}
.hidden {
opacity: 0;
}
#text-overlay {
position: absolute;
bottom: 0;
width: 100%;
text-align: left;
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1));
/* background: rgba(0, 0, 0, 0.5); */
/* Semi-transparent background */
color: rgba(255, 255, 255, 0.8);
/* padding: 20px; */
padding-left: 10px;
padding-right: 30px;
font-size: 7pt;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
max-width: 100%;
}
#author-date {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
}
#message-text {
text-shadow: 1px 1px 2px black;
word-wrap: break-word;
overflow-wrap: break-word;
margin-right: 5px;
font-size: initial;
}
#top-left-button {
position: absolute;
/* Distance from top */
top: 10px;
/* Distance from left */
left: 10px;
/* Ensures it's in front of the image */
z-index: 1;
/* Semi-transparent background */
background-color: rgba(0, 0, 0, 0);
/* White text */
color: rgba(255, 255, 255, 0.2);
padding-top: 5px;
padding-bottom: 5px;
border: none;
border-radius: 5px;
font-size: 14px;
cursor: pointer;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeSlideOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(20px);
}
}
.show {
animation: fadeSlideIn 0.3s cubic-bezier(0.83, 0, 0.17, 1);
}
.hide {
animation: fadeSlideOut 0.3s ease-in forwards;
}
#hide-unhide {
width: 20px;
opacity: 0.2;
}
On the backend, it looks like this:
const express = require('express');
const mysql = require('mysql2/promise');
const fs = require('fs').promises;
const fileUpload = require('express-fileupload');
const file_utils = require("./public/js/utils/file_utils.js");
const date_utils = require("./public/js/utils/date_utils.js");
const SqlString = require('sqlstring');
const pool = mysql.createPool({
....
});
async function testConnection() {
try {
const connection = await pool.getConnection();
console.log('Connected to MySQL successfully!');
connection.release();
} catch (error) {
console.error('Connection failed:', error);
}
}
testConnection();
const app = express();
const port = ...;
app.use(express.static('public'));
app.use('/css', express.static(__dirname + 'public/css'));
app.use('/js', express.static(__dirname + 'public/js'));
app.use('/img', express.static(__dirname + 'public/img'));
app.set('views', './views');
app.set('view engine', 'ejs');
app.get("/", (req, res) => {
res.render("index");
})
app.get('/upload', (req, res) => {
res.render("upload");
});
app.use(fileUpload());
app.post("/api/upload", async (req, res) => {
var has_files = false;
if (req.body.name && req.body.message_text) {
console.log("Name: " + req.body.name + ", msg: " + req.body.message_text)
} else {
console.log("No shit provided bruh");
}
let filename = null;
if (req.files && Object.keys(req.files).length != 0) {
console.log('handling file upload');
try {
filename = await file_utils.handle_file_upload_promise(req, __dirname + '/public/img/upload/');
console.log("File uploaded successfully! " + filename);
has_files = true;
} catch (err) {
console.log("Error in handling file upload:", err);
return res.status(500).send("Error in handling file upload: " + err);
}
} else {
console.log("no image? :(");
}
try {
const connection = await pool.getConnection();
var query_string = "";
var params = [];
if (has_files) {
query_string = "INSERT INTO messages (message, author, image_location, date) VALUES (?, ?, ?, NOW())"
params = [req.body.message_text, req.body.name, filename]
} else {
query_string = "INSERT INTO messages (message, author, date) VALUES (?, ?, NOW())"
params = [req.body.message_text, req.body.name]
}
connection.query(SqlString.format(query_string, params));
connection.commit();
connection.release();
res.status(200).send("success");
console.log("Successfully handled uploading message!");
} catch (error) {
console.error('Connection failed:', error);
}
});
/** Gets the last message that was added after the added_since parameter
* The request contains:
* - added_since: Time in seconds to subtract from current time when checking the timestamp of messages
* the response contains:
* - message: json object containing the message. Null if there was no message after the provided timestamp
*/
app.get("/api/messages/last-added", async (req, res) => {
const connection = await pool.getConnection();
if (req.query.added_since == null) {
return res.status(400).send("Required parameter added_since not found");
}
var new_date = new Date();
var added_since_timestamp = new_date.today() + " " + (new_date.timeNowSubtract(req.query.added_since));
const query = SqlString.format("SELECT * FROM messages WHERE date >= ? ORDER BY date DESC LIMIT 1", added_since_timestamp);
const [rows] = await connection.query(query);
connection.release();
return res.status(200).json({ message: rows[0] });
});
/** Gets a random message in the database
* The request contains:
* - used_ids: JSON list of message IDs already used. Can be omitted if the list is empty
* the response contains:
* - message: json object containing the message
* - last: boolean indicating if this is the last message that is not in the IDs list.
* If that's the case, the next request can be sent with an empty list
*/
app.get("/api/messages", async (req, res) => {
const connection = await pool.getConnection();
// if list is omitted, choose a random row
let used_ids = req.query.used_ids ? JSON.parse(req.query.used_ids) : [];
if (used_ids.length == 0) {
const rows = await get_random_message_from_db(connection);
connection.release();
return res.status(200).json({ last: false, message: rows[0] });
} else {
// if the used_ids is the same length as the items in the DB,
// it means all messages have been viewed. Start again from the beginning
const [amount_of_messages_rows] = await connection.query("SELECT COUNT(id) AS count FROM messages");
var formatted_query = `SELECT * FROM messages WHERE id NOT IN (${used_ids.map(() => "?").join(",")}) ORDER BY RAND() LIMIT 1`;
var query = SqlString.format(formatted_query, used_ids);
const [rows] = await connection.query(query);
connection.release();
// add 1 to the used ids because we just got another id
const amount_of_ids_used = used_ids.length + 1;
// if we have used all the ids, set last to true
const last = amount_of_ids_used == amount_of_messages_rows[0].count;
return res.status(200).json({ last: last, message: rows[0] });
}
});
async function get_random_message_from_db(connection) {
var query = "SELECT * FROM messages ORDER BY RAND() LIMIT 1";
const [rows] = await connection.query(query);
return rows;
}
app.listen(port, () => {
console.log("Listening on port ...");
});
The date_utils.js file looks like this:
// get todays date in YYYY-MM-DD
Date.prototype.today = function () {
return this.getFullYear() + "-" + (((this.getMonth() + 1) < 10) ? "0" : "") + (this.getMonth() + 1) + "-" + ((this.getDate() < 10) ? "0" : "") + this.getDate();
}
// get the time now in HH:MM:SS
Date.prototype.timeNowSubtract = function (secondsSubtract) {
var hours = this.getHours();
if (hours < 0) {
hours = 23;
}
var minutes = this.getMinutes();
var seconds = this.getSeconds();
var newSeconds = seconds - secondsSubtract;
let finalSeconds = newSeconds;
if (newSeconds < 0) {
finalSeconds = 60 + newSeconds;
minutes = minutes - 1;
if (minutes < 0) {
minutes = 59;
hours = hours - 1;
if (hours < 0) {
hours = 23;
}
}
}
return ((hours < 10) ? "0" : "") + hours + ":" + ((minutes < 10) ? "0" : "") + minutes + ":" + ((finalSeconds < 10) ? "0" : "") + finalSeconds;
}
And the file_utils.js file looks like this:
const fs = require('fs');
const fsPromises = require('fs').promises;
function handle_file_upload(req, resultPath, successCallback, errorCallback) {
console.log("Handling upload of file")
let sampleFile;
let uploadPath;
if (!req.files || Object.keys(req.files).length === 0) {
return res.status(400).send('No files were uploaded.');
}
// The name of the input field (i.e. "filename") is used to retrieve the uploaded file
sampleFile = req.files.filename;
uploadPath = resultPath + sampleFile.name;
// Use the mv() method to place the file somewhere on your server
sampleFile.mv(uploadPath, function (err) {
if (err)
errorCallback(err);
console.log("File uploaded to " + uploadPath);
successCallback(sampleFile.name);
});
}
function handle_file_upload_promise(req, uploadPath) {
return new Promise((resolve, reject) => {
handle_file_upload(req, uploadPath,
(filename) => resolve(filename),
(err) => reject(err)
);
})
}
function delete_file(file_path, successCallback, errorCallback) {
fs.unlink(file_path, (err) => {
if (err) {
console.error(err)
errorCallback(err);
}
console.log("File deleted successfully!");
successCallback();
})
}
/**
*
* @param {string} file_path path to the directory to get the files from
* @param {*} successCallback callback for when getting the files was successful
* @param {*} errorCallback callback for when an error occurred
*/
function get_files_in_directory(file_path, successCallback, errorCallback) {
fs.readdir(file_path, (err, files) => {
if (err) {
console.error(err);
errorCallback(err);
}
console.log("Files in directory: ", files);
successCallback(files);
});
}
/**
*
* @param {string} file_path The path to the directory to get the files and directories from
* @returns a json object with the files and directories in the directory. This contains: name (string), isFile (bool), isDirectory (bool)
*/
async function get_files_and_directories_in_directory(file_path) {
try {
const dir = await fsPromises.opendir(file_path);
const results = [];
for await (const dirent of dir) {
results.push({
name: dirent.name,
isFile: dirent.isFile(),
isDirectory: dirent.isDirectory(),
});
}
// pretty print with 2 spaces
const results_json = JSON.parse(JSON.stringify(results, null, 2));
// console.log("Files and directories in directory: ", results_json);
return results_json;
} catch (err) {
console.error(err);
return "";
}
}
module.exports.handle_file_upload = handle_file_upload;
module.exports.handle_file_upload_promise = handle_file_upload_promise;
module.exports.delete_file = delete_file;
module.exports.get_files_in_directory = get_files_in_directory;
module.exports.get_files_and_directories_in_directory = get_files_and_directories_in_directory;
And the string_utils.js file looks like this:
// remove whitespaces from the end of a string
function rtrim(x) {
return x.replace(/\s+$/gm, '');
}
const _rtrim = rtrim;
export { _rtrim as rtrim };