SDL: Detect device form factor (#12584)

From d4747698786bde74dfc7cd14687d5d9f3f25b39a Mon Sep 17 00:00:00 2001
From: Semphriss <[EMAIL REDACTED]>
Date: Sun, 21 Jun 2026 11:33:13 -0400
Subject: [PATCH] Detect device form factor (#12584)

---
 .../main/java/org/libsdl/app/SDLActivity.java | 19 ++++++
 include/SDL3/SDL_system.h                     | 58 +++++++++++++++++++
 src/SDL.c                                     | 57 +++++++++++++-----
 src/core/android/SDL_android.c                | 36 ++++++++++++
 src/core/android/SDL_android.h                |  1 +
 src/dynapi/SDL_dynapi.exports                 |  2 +
 src/dynapi/SDL_dynapi.sym                     |  2 +
 src/dynapi/SDL_dynapi_overrides.h             |  2 +
 src/dynapi/SDL_dynapi_procs.h                 |  2 +
 src/video/uikit/SDL_uikitvideo.m              | 24 ++++++--
 10 files changed, 184 insertions(+), 19 deletions(-)

diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
index 0ae1bcc99d4ef..1f15b4f719cc1 100644
--- a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
+++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
@@ -1345,6 +1345,25 @@ public static boolean isVRHeadset() {
         return false;
     }
 
+    /**
+     * This method is called by SDL using JNI.
+     */
+    static String getDeviceFormFactor()
+    {
+        // TODO: WearOS
+        if (isAndroidTV()) {
+            return "tv";
+        } else if (isVRHeadset()) {
+            return "headset";
+        } else if (isTablet()) {
+            return "tablet";
+        //} else if (isAndroidAutomotive()) {
+        //    return "car";
+        } else {
+            return "phone";
+        }
+    }
+
     public static double getDiagonal()
     {
         DisplayMetrics metrics = new DisplayMetrics();
diff --git a/include/SDL3/SDL_system.h b/include/SDL3/SDL_system.h
index 3353bb7741b92..47a3c3e9b3940 100644
--- a/include/SDL3/SDL_system.h
+++ b/include/SDL3/SDL_system.h
@@ -655,6 +655,64 @@ extern SDL_DECLSPEC bool SDLCALL SDL_IsTablet(void);
  */
 extern SDL_DECLSPEC bool SDLCALL SDL_IsTV(void);
 
+/**
+ * The possible form factors for a device.
+ *
+ * \since This enum is available since SDL 3.4.0.
+ *
+ * \sa SDL_GetDeviceFormFactor
+ * \sa SDL_GetDeviceFormFactorName
+ */
+typedef enum SDL_FormFactor {
+    SDL_FORMFACTOR_UNKNOWN = 0,
+    SDL_FORMFACTOR_DESKTOP,
+    SDL_FORMFACTOR_LAPTOP,
+    SDL_FORMFACTOR_PHONE,
+    SDL_FORMFACTOR_TABLET,
+    SDL_FORMFACTOR_CONSOLE,
+    SDL_FORMFACTOR_HANDHELD,
+    SDL_FORMFACTOR_WATCH,
+    SDL_FORMFACTOR_TV,
+    SDL_FORMFACTOR_HEADSET,
+    SDL_FORMFACTOR_CAR
+} SDL_FormFactor;
+
+/**
+ * Get the form factor of the current device.
+ *
+ * This function guesses what the device may be, but may report inaccurate or
+ * outright wrong results. For example, it may report a laptop as a desktop, or
+ * a car device as a phone.
+ *
+ * Depending on the usage, there may be different functions better suited for
+ * each purpose. For example, activating touch controls can be done by detecting
+ * the presence of a touchscreen rather than restricting to phones and tablets.
+ *
+ * \returns the best guess for the form factor of the current device.
+ *
+ * \since This function is available since SDL 3.4.0.
+ *
+ * \sa SDL_FormFactor
+ * \sa SDL_GetDeviceFormFactorName
+ */
+extern SDL_DECLSPEC SDL_FormFactor SDLCALL SDL_GetDeviceFormFactor(void);
+
+/**
+ * Get a short name for the current device.
+ *
+ * The name will be in English.
+ *
+ * \param form_factor the form factor to query.
+ * \returns a human-readable name for the given form factor, or
+ *          "SDL_FORMFACTOR_UNKNOWN" if the form factor isn't recognized.
+ *
+ * \since This function is available since SDL 3.4.0.
+ *
+ * \sa SDL_FormFactor
+ * \sa SDL_GetDeviceFormFactor
+ */
+extern SDL_DECLSPEC const char* SDLCALL SDL_GetDeviceFormFactorName(SDL_FormFactor form_factor);
+
 /**
  * Application sandbox environment.
  *
diff --git a/src/SDL.c b/src/SDL.c
index d3b3b09c934da..a903b16743081 100644
--- a/src/SDL.c
+++ b/src/SDL.c
@@ -849,28 +849,59 @@ bool SDL_IsPhone(void)
 
 bool SDL_IsTablet(void)
 {
-#ifdef SDL_PLATFORM_ANDROID
-    return SDL_IsAndroidTablet();
-#elif defined(SDL_PLATFORM_IOS)
-    extern bool SDL_IsIPad(void);
-    return SDL_IsIPad();
-#else
-    return false;
-#endif
+    return SDL_GetDeviceFormFactor() == SDL_FORMFACTOR_TABLET;
 }
 
 bool SDL_IsTV(void)
 {
-#ifdef SDL_PLATFORM_ANDROID
-    return SDL_IsAndroidTV();
+    return SDL_GetDeviceFormFactor() == SDL_FORMFACTOR_TV;
+}
+
+SDL_FormFactor SDL_GetDeviceFormFactor(void)
+{
+#ifdef SDL_FORMFACTOR_PRIVATE
+    return SDL_FORMFACTOR_PRIVATE;
+#elif defined(SDL_PLATFORM_ANDROID)
+    return SDL_GetAndroidDeviceFormFactor();
 #elif defined(SDL_PLATFORM_IOS)
-    extern bool SDL_IsAppleTV(void);
-    return SDL_IsAppleTV();
+    extern bool SDL_GetUIKitDeviceFormFactor(void);
+    return SDL_GetUIKitDeviceFormFactor();
+#elif defined(SDL_PLATFORM_XBOXONE) || defined(SDL_PLATFORM_XBOXSERIES) || defined(SDL_PLATFORM_PS2)
+    return SDL_FORMFACTOR_CONSOLE;
+#elif defined(SDL_PLATFORM_PSP) || defined(SDL_PLATFORM_VITA) || defined(SDL_PLATFORM_3DS)
+    return SDL_FORMFACTOR_HANDHELD;
+#elif defined(SDL_PLATFORM_QNXNTO)
+    /* TODO: QNX is used in BlackBerry phones and tablets, and in many embedded devices */
+    return SDL_FORMFACTOR_UNKNOWN;
+#elif defined(SDL_PLATFORM_WINGDK)
+    /* TODO: GDK can be either desktop Windows or XBox */
+    return SDL_FORMFACTOR_UNKNOWN;
 #else
-    return false;
+    return SDL_FORMFACTOR_DESKTOP;
 #endif
 }
 
+const char* SDL_GetDeviceFormFactorName(SDL_FormFactor form_factor)
+{
+    switch (form_factor)
+    {
+#define CASE(x) case x: return #x;
+    default:
+    CASE(SDL_FORMFACTOR_UNKNOWN)
+    CASE(SDL_FORMFACTOR_DESKTOP)
+    CASE(SDL_FORMFACTOR_LAPTOP)
+    CASE(SDL_FORMFACTOR_PHONE)
+    CASE(SDL_FORMFACTOR_TABLET)
+    CASE(SDL_FORMFACTOR_CONSOLE)
+    CASE(SDL_FORMFACTOR_HANDHELD)
+    CASE(SDL_FORMFACTOR_WATCH)
+    CASE(SDL_FORMFACTOR_TV)
+    CASE(SDL_FORMFACTOR_HEADSET)
+    CASE(SDL_FORMFACTOR_CAR)
+#undef CASE
+    }
+}
+
 static SDL_Sandbox SDL_DetectSandbox(void)
 {
 #if defined(SDL_PLATFORM_LINUX)
diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c
index 6b532e71bea56..3f0b4c2521a51 100644
--- a/src/core/android/SDL_android.c
+++ b/src/core/android/SDL_android.c
@@ -369,6 +369,7 @@ static jmethodID midClipboardSetText;
 static jmethodID midCreateCustomCursor;
 static jmethodID midDestroyCustomCursor;
 static jmethodID midGetContext;
+static jmethodID midGetDeviceFormFactor;
 static jmethodID midGetManifestEnvironmentVariables;
 static jmethodID midGetNativeSurface;
 static jmethodID midInitTouch;
@@ -659,6 +660,7 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(nativeSetupJNI)(JNIEnv *env, jclass cl
     midCreateCustomCursor = (*env)->GetStaticMethodID(env, mActivityClass, "createCustomCursor", "([IIIII)I");
     midDestroyCustomCursor = (*env)->GetStaticMethodID(env, mActivityClass, "destroyCustomCursor", "(I)V");
     midGetContext = (*env)->GetStaticMethodID(env, mActivityClass, "getContext", "()Landroid/app/Activity;");
+    midGetDeviceFormFactor = (*env)->GetStaticMethodID(env, mActivityClass, "getDeviceFormFactor", "()Ljava/lang/String;");
     midGetManifestEnvironmentVariables = (*env)->GetStaticMethodID(env, mActivityClass, "getManifestEnvironmentVariables", "()Z");
     midGetNativeSurface = (*env)->GetStaticMethodID(env, mActivityClass, "getNativeSurface", "()Landroid/view/Surface;");
     midInitTouch = (*env)->GetStaticMethodID(env, mActivityClass, "initTouch", "()V");
@@ -691,6 +693,7 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(nativeSetupJNI)(JNIEnv *env, jclass cl
         !midCreateCustomCursor ||
         !midDestroyCustomCursor ||
         !midGetContext ||
+        !midGetDeviceFormFactor ||
         !midGetManifestEnvironmentVariables ||
         !midGetNativeSurface ||
         !midInitTouch ||
@@ -3505,4 +3508,37 @@ bool Android_JNI_ShowFileDialog(
     return true;
 }
 
+SDL_FormFactor SDL_GetAndroidDeviceFormFactor(void)
+{
+    JNIEnv *env = Android_JNI_GetEnv();
+    SDL_FormFactor form_factor = SDL_FORMFACTOR_UNKNOWN;
+    jstring string;
+
+    string = (*env)->CallStaticObjectMethod(env, mActivityClass, midGetDeviceFormFactor);
+    if (string) {
+        const char *utf = (*env)->GetStringUTFChars(env, string, 0);
+        if (utf) {
+            if (SDL_strcmp(utf, "tv") == 0) {
+                form_factor = SDL_FORMFACTOR_TV;
+            } else if (SDL_strcmp(utf, "tablet") == 0) {
+                form_factor = SDL_FORMFACTOR_TABLET;
+            } else if (SDL_strcmp(utf, "phone") == 0) {
+                form_factor = SDL_FORMFACTOR_PHONE;
+            } else if (SDL_strcmp(utf, "car") == 0) {
+                form_factor = SDL_FORMFACTOR_CAR;
+            } else if (SDL_strcmp(utf, "headset") == 0) {
+                form_factor = SDL_FORMFACTOR_HEADSET;
+            } else if (SDL_strcmp(utf, "watch") == 0) {
+                form_factor = SDL_FORMFACTOR_WATCH;
+            } else {
+                form_factor = SDL_FORMFACTOR_UNKNOWN;
+            }
+            (*env)->ReleaseStringUTFChars(env, string, utf);
+        }
+        (*env)->DeleteLocalRef(env, string);
+    }
+
+    return form_factor;
+}
+
 #endif // SDL_PLATFORM_ANDROID
diff --git a/src/core/android/SDL_android.h b/src/core/android/SDL_android.h
index fd16927595953..b5faa2ff49348 100644
--- a/src/core/android/SDL_android.h
+++ b/src/core/android/SDL_android.h
@@ -151,6 +151,7 @@ int SDL_GetAndroidSDKVersion(void);
 
 bool SDL_IsAndroidTablet(void);
 bool SDL_IsAndroidTV(void);
+SDL_FormFactor SDL_GetAndroidDeviceFormFactor(void);
 
 char *SDL_GetAndroidPackageName(void);  // this is a SDL_malloc'd string the caller will own.
 
diff --git a/src/dynapi/SDL_dynapi.exports b/src/dynapi/SDL_dynapi.exports
index 05af5c7b89ccd..f461c2c074d95 100644
--- a/src/dynapi/SDL_dynapi.exports
+++ b/src/dynapi/SDL_dynapi.exports
@@ -1298,3 +1298,5 @@ _SDL_RequestNotificationPermission
 _SDL_ShowNotificationWithProperties
 _SDL_ShowNotification
 _SDL_RemoveNotification
+_SDL_GetDeviceFormFactor
+_SDL_GetDeviceFormFactorName
diff --git a/src/dynapi/SDL_dynapi.sym b/src/dynapi/SDL_dynapi.sym
index 725f62128234c..778de467103ce 100644
--- a/src/dynapi/SDL_dynapi.sym
+++ b/src/dynapi/SDL_dynapi.sym
@@ -1299,6 +1299,8 @@ SDL3_0.0.0 {
     SDL_ShowNotificationWithProperties;
     SDL_ShowNotification;
     SDL_RemoveNotification;
+    SDL_GetDeviceFormFactor;
+    SDL_GetDeviceFormFactorName;
     # extra symbols go here (don't modify this line)
   local: *;
 };
diff --git a/src/dynapi/SDL_dynapi_overrides.h b/src/dynapi/SDL_dynapi_overrides.h
index 0c25bff621b6d..1ce6de5fae5ca 100644
--- a/src/dynapi/SDL_dynapi_overrides.h
+++ b/src/dynapi/SDL_dynapi_overrides.h
@@ -1325,3 +1325,5 @@
 #define SDL_ShowNotificationWithProperties SDL_ShowNotificationWithProperties_REAL
 #define SDL_ShowNotification SDL_ShowNotification_REAL
 #define SDL_RemoveNotification SDL_RemoveNotification_REAL
+#define SDL_GetDeviceFormFactor SDL_GetDeviceFormFactor_REAL
+#define SDL_GetDeviceFormFactorName SDL_GetDeviceFormFactorName_REAL
diff --git a/src/dynapi/SDL_dynapi_procs.h b/src/dynapi/SDL_dynapi_procs.h
index 69c229401ca80..807dbc263504e 100644
--- a/src/dynapi/SDL_dynapi_procs.h
+++ b/src/dynapi/SDL_dynapi_procs.h
@@ -1333,3 +1333,5 @@ SDL_DYNAPI_PROC(bool,SDL_RequestNotificationPermission,(void),(),return)
 SDL_DYNAPI_PROC(SDL_NotificationID,SDL_ShowNotificationWithProperties,(SDL_PropertiesID a),(a),return)
 SDL_DYNAPI_PROC(SDL_NotificationID,SDL_ShowNotification,(const char *a,const char *b,SDL_Surface *c,SDL_NotificationAction *d,int e),(a,b,c,d,e),return)
 SDL_DYNAPI_PROC(bool,SDL_RemoveNotification,(SDL_NotificationID a),(a),return)
+SDL_DYNAPI_PROC(SDL_FormFactor,SDL_GetDeviceFormFactor,(void),(),return)
+SDL_DYNAPI_PROC(const char*,SDL_GetDeviceFormFactorName,(SDL_FormFactor a),(a),return)
diff --git a/src/video/uikit/SDL_uikitvideo.m b/src/video/uikit/SDL_uikitvideo.m
index d31f70a13d572..f5bf626b782dc 100644
--- a/src/video/uikit/SDL_uikitvideo.m
+++ b/src/video/uikit/SDL_uikitvideo.m
@@ -384,14 +384,26 @@ void SDL_NSLog(const char *prefix, const char *text)
  * This doesn't really have anything to do with the interfaces of the SDL video
  * subsystem, but we need to stuff this into an Objective-C source code file.
  */
-bool SDL_IsIPad(void)
-{
-    return ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad);
-}
 
-bool SDL_IsAppleTV(void)
+bool SDL_GetUIKitDeviceFormFactor(void)
 {
-    return ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomTV);
+    // TODO: Apple Watch
+    switch ([UIDevice currentDevice].userInterfaceIdiom) {
+    case UIUserInterfaceIdiomPhone:
+        return SDL_FORMFACTOR_PHONE;
+    case UIUserInterfaceIdiomPad:
+        return SDL_FORMFACTOR_TABLET;
+    case UIUserInterfaceIdiomTV:
+        return SDL_FORMFACTOR_TV;
+    case UIUserInterfaceIdiomCarPlay:
+        return SDL_FORMFACTOR_CAR;
+    case UIUserInterfaceIdiomMac:
+        return SDL_FORMFACTOR_DESKTOP;
+    case UIUserInterfaceIdiomVision:
+        return SDL_FORMFACTOR_HEADSET;
+    default:
+        return SDL_FORMFACTOR_UNKNOWN;
+    }
 }
 
 #endif // SDL_VIDEO_DRIVER_UIKIT