gif gif

The interesting corner

gif gif

Making a birthday present from an old Android phone

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

connector pcb soldered wires

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!

finished electronics

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:

finished print finished print finished print finished print

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:

  1. Set the view to edge-to-edge mode (not really necessary as we open a new app)
  2. Start a service that keeps the screen awake
  3. Start an intent to launch the browser window with the specified URL.
  4. Listen for a BootCompleted event to start the app automatically when the phone boots.
The running service is used to get a wakelock and to make sure the screen stays on. After that, when the app is started, it automatically launches the browser intent to open the website. The MainActivity.java file looks like this:

        
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:

        

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppName"
        tools:targetApi="31">
        <service android:name=".RunningService" />
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <receiver
            android:name=".BootCompletedReceiver"
            android:exported="false">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            </intent-filter>
        </receiver>
    </application>
</manifest>

        
      

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:

        
          
            <div class="container">
              <img src="" alt="" id="message-image">
              <button id="top-left-button" onclick="toggleText()">
                  <img id="hide-unhide" src="/img/text icon.png" alt="Hide/unhide">
              </button>
              <div id="text-overlay">
                  <h1 id="message-text"></h1>
                  <h1 id="author-date"></h1>
              </div>
          </div>
          
        
      

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 };