Files
ppsspp/ios/ViewControllerCommon.mm
T
Henrik RydgårdandClaude Opus 5.5 cfcf8cc17c iOS: Remove the iCade support
It has done nothing since 2017. iCade input came through iCadeReaderView,
a hidden UIKeyInput view that decoded the controller's keystrokes, and
f0ba66fcd6 ("add support for smart keyboard") commented out the code
that created it. Nothing has made one since, so the view controller's
iCadeEventDelegate methods and the ICadeTracker behind them were never
called.

iCade controllers present themselves to iOS as Bluetooth keyboards, so
their keys still arrive as hardware keyboard presses, which can be
mapped like any other keys. Modern controllers go through
GameController.framework anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:10:37 +02:00

578 lines
19 KiB
Plaintext

#import "ios/CameraHelper.h"
#import "ios/ViewControllerCommon.h"
#import "ios/Controls.h"
#import "ios/IAPManager.h"
#import <Photos/Photos.h>
#import <objc/runtime.h>
#include "Common/System/Request.h"
#include "Common/Input/InputState.h"
#include "Common/System/NativeApp.h"
#include "Common/System/Display.h"
#include "Common/Log.h"
#include "Core/HLE/sceUsbCam.h"
#include "Core/HLE/sceUsbGps.h"
#include "Core/System.h"
#include "Core/Config.h"
// The target of the soft keyboard. It's a view of its own so the keyboard only comes up when we ask
// for it: UIKit hands first responder back to the view controller whenever something it presented
// (a share sheet, a picker, an alert) goes away, and if that were the text input, up it would come.
@interface KeyboardInputView : UIView <UIKeyInput>
@end
@implementation KeyboardInputView
- (BOOL)canBecomeFirstResponder {
return YES;
}
- (BOOL)hasText {
// Otherwise the keyboard wouldn't send backspaces.
return YES;
}
- (void)insertText:(NSString *)text {
SendKeyboardChars([text UTF8String]);
}
- (void)deleteBackward {
KeyInput input{};
input.deviceId = DEVICE_ID_KEYBOARD;
input.flags = KeyInputFlags::DOWN | KeyInputFlags::UP;
input.keyCode = NKCODE_DEL;
NativeKey(input);
}
@end
@interface PPSSPPBaseViewController () {
CameraHelper *cameraHelper;
LocationHelper *locationHelper;
TouchTracker g_touchTracker;
}
@property (strong, nonatomic) NSOperationQueue *accelerometerQueue;
@property (nonatomic) GCController *gameController;
@property (strong, nonatomic) CMMotionManager *motionManager;
@end
@implementation PPSSPPBaseViewController {
UIScreenEdgePanGestureRecognizer *mBackGestureRecognizer;
CGSize lastLayoutSize_;
KeyboardInputView *keyboardInputView_;
}
// Strange idiom for generating unique IDs (within the process, at least).
static const void *kPickerFilenameKey = &kPickerFilenameKey;
static const void *kPickerRequestIdKey = &kPickerRequestIdKey;
static void SetPickerContext(id picker, NSString *filename, int requestId) {
objc_setAssociatedObject(picker, kPickerFilenameKey, filename, OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(picker, kPickerRequestIdKey, @(requestId), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
static NSString *GetPickerFilename(id picker) {
return (NSString *)objc_getAssociatedObject(picker, kPickerFilenameKey);
}
static int GetPickerRequestId(id picker) {
NSNumber *requestIdNumber = (NSNumber *)objc_getAssociatedObject(picker, kPickerRequestIdKey);
return requestIdNumber ? requestIdNumber.intValue : -1;
}
- (void)savePickedImage:(UIImage *)image toFilename:(NSString *)targetFilename requestId:(int)requestId {
NSData *jpegData = UIImageJPEGRepresentation(image, 0.9);
if (jpegData) {
[jpegData writeToFile:targetFilename atomically:YES];
NSLog(@"Saved JPEG image to %@", targetFilename);
g_requestManager.PostSystemSuccess(requestId, "", 1);
} else {
g_requestManager.PostSystemFailure(requestId);
}
}
- (id)init {
self = [super init];
if (self) {
sharedViewController = self;
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appWillTerminate:) name:UIApplicationWillTerminateNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidConnect:) name:GCControllerDidConnectNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidDisconnect:) name:GCControllerDidDisconnectNotification object:nil];
// Observe orientation changes
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onOrientationChanged)
name:UIDeviceOrientationDidChangeNotification
object:nil];
self.accelerometerQueue = [[NSOperationQueue alloc] init];
self.accelerometerQueue.name = @"AccelerometerQueue";
self.accelerometerQueue.maxConcurrentOperationCount = 1;
}
return self;
}
// Only means anything for the GL backend, which overrides this.
- (void)bindDefaultFBO {}
- (void)shutdown {
self.gameController = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
_dbg_assert_(sharedViewController != nil);
sharedViewController = nil;
}
- (BOOL)prefersHomeIndicatorAutoHidden {
if (g_Config.iAppSwitchMode == (int)AppSwitchMode::DOUBLE_SWIPE_INDICATOR) {
return NO;
} else {
return YES;
}
}
- (void)didBecomeActive {
if (self.motionManager.accelerometerAvailable) {
self.motionManager.accelerometerUpdateInterval = 1.0 / 60.0;
INFO_LOG(Log::G3D, "Starting accelerometer updates.");
[self.motionManager startAccelerometerUpdatesToQueue:self.accelerometerQueue
withHandler:^(CMAccelerometerData *accelerometerData, NSError *error) {
if (error) {
NSLog(@"Accelerometer error: %@", error);
return;
}
ProcessAccelerometerData(accelerometerData);
}];
} else {
INFO_LOG(Log::G3D, "No accelerometer available, not starting updates.");
}
}
- (void)willResignActive {
// Stop accelerometer updates
if (self.motionManager.accelerometerActive) {
INFO_LOG(Log::G3D, "Stopping accelerometer updates");
[self.motionManager stopAccelerometerUpdates];
}
}
- (void)appWillTerminate:(NSNotification *)notification {
[self shutdown];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
INFO_LOG(Log::G3D, "viewDidAppear");
// For hardware keyboard presses.
[self becomeFirstResponder];
[self updateGesture];
// This needs to be called really late during startup, unfortunately.
#if PPSSPP_PLATFORM(IOS_APP_STORE)
[IAPManager sharedIAPManager]; // Kick off the IAPManager early.
NSLog(@"viewDidAppear. updating icon");
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[[IAPManager sharedIAPManager] updateIcon:false];
});
#endif // IOS_APP_STORE
}
// Enables tapping for edge area.
-(UIRectEdge)preferredScreenEdgesDeferringSystemGestures {
if (GetUIState() == UISTATE_INGAME) {
// In-game, we need all the control we can get. Though, we could possibly
// allow the top edge?
INFO_LOG(Log::System, "Defer system gestures on all edges");
return UIRectEdgeAll;
} else {
INFO_LOG(Log::System, "Allow system gestures on the bottom");
// Allow task switching gestures to take precedence, without causing
// scroll events in the UI. Otherwise, we get "ghost" scrolls when switching tasks.
return UIRectEdgeTop | UIRectEdgeLeft | UIRectEdgeRight;
}
}
- (void)controllerDidConnect:(NSNotification *)note
{
if (![[GCController controllers] containsObject:self.gameController]) self.gameController = nil;
if (self.gameController != nil) return; // already have a connected controller
[self setupController:(GCController *)note.object];
}
- (void)controllerDidDisconnect:(NSNotification *)note
{
if (self.gameController == note.object) {
ShutdownController(self.gameController);
self.gameController = nil;
if ([[GCController controllers] count] > 0) {
[self setupController:[[GCController controllers] firstObject]];
}
}
}
- (void)setupController:(GCController *)controller {
self.gameController = controller;
if (!InitController(controller)) {
self.gameController = nil;
}
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
g_touchTracker.Began(touches, self.view);
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
g_touchTracker.Moved(touches, self.view);
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
g_touchTracker.Ended(touches, self.view);
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
g_touchTracker.Cancelled(touches, self.view);
}
- (void)pickPhoto:(NSString *)saveFilename requestId:(int)requestId {
NSLog(@"Picking photo to save to %@ (id: %d)", saveFilename, requestId);
NSString *targetFilename = [saveFilename copy];
if (@available(iOS 14.0, *)) {
PHPickerConfiguration *config = [[PHPickerConfiguration alloc] initWithPhotoLibrary:PHPhotoLibrary.sharedPhotoLibrary];
config.selectionLimit = 1;
config.filter = [PHPickerFilter imagesFilter];
PHPickerViewController *picker = [[PHPickerViewController alloc] initWithConfiguration:config];
picker.delegate = self;
SetPickerContext(picker, targetFilename, requestId);
[self presentViewController:picker animated:YES completion:nil];
return;
}
UIImagePickerController *picker = [[UIImagePickerController alloc] init];
picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
// Images only keeps the picker from doing extra video-related setup.
picker.mediaTypes = @[@"public.image"];
picker.delegate = self;
SetPickerContext(picker, targetFilename, requestId);
[self presentViewController:picker animated:YES completion:nil];
}
- (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray<PHPickerResult *> *)results API_AVAILABLE(ios(14.0)) {
NSString *targetFilename = GetPickerFilename(picker);
int requestId = GetPickerRequestId(picker);
[picker dismissViewControllerAnimated:YES completion:nil];
if (results.count == 0) {
NSLog(@"User cancelled photo picker");
g_requestManager.PostSystemFailure(requestId);
return;
}
NSItemProvider *itemProvider = results.firstObject.itemProvider;
if (![itemProvider canLoadObjectOfClass:[UIImage class]]) {
NSLog(@"Photo picker result does not provide UIImage");
g_requestManager.PostSystemFailure(requestId);
return;
}
[itemProvider loadObjectOfClass:[UIImage class] completionHandler:^(UIImage *image, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (error || !image) {
NSLog(@"Failed loading picked image: %@", error);
g_requestManager.PostSystemFailure(requestId);
} else {
[self savePickedImage:image toFilename:targetFilename requestId:requestId];
}
});
}];
}
- (void)imagePickerController:(UIImagePickerController *)picker
didFinishPickingMediaWithInfo:(NSDictionary<UIImagePickerControllerInfoKey,id> *)info {
NSString *targetFilename = GetPickerFilename(picker);
int requestId = GetPickerRequestId(picker);
UIImage *image = info[UIImagePickerControllerOriginalImage];
[self savePickedImage:image toFilename:targetFilename requestId:requestId];
[picker dismissViewControllerAnimated:YES completion:nil];
}
- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
NSLog(@"User cancelled image picker");
int requestId = GetPickerRequestId(picker);
[picker dismissViewControllerAnimated:YES completion:nil];
g_requestManager.PostSystemFailure(requestId);
}
- (void)handleSwipeFrom:(UIScreenEdgePanGestureRecognizer *)recognizer {
if (recognizer.state == UIGestureRecognizerStateEnded) {
KeyInput key;
key.flags = KeyInputFlags::DOWN | KeyInputFlags::UP;
key.keyCode = NKCODE_BACK;
key.deviceId = DEVICE_ID_TOUCH;
NativeKey(key);
INFO_LOG(Log::System, "Detected back swipe");
}
}
- (void)updateGesture {
INFO_LOG(Log::System, "Updating swipe gesture.");
if (mBackGestureRecognizer) {
INFO_LOG(Log::System, "Removing swipe gesture.");
[[self view] removeGestureRecognizer:mBackGestureRecognizer];
mBackGestureRecognizer = nil;
}
if (GetUIState() != UISTATE_INGAME) {
INFO_LOG(Log::System, "Adding swipe gesture.");
mBackGestureRecognizer = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handleSwipeFrom:) ];
[mBackGestureRecognizer setEdges:UIRectEdgeLeft];
[[self view] addGestureRecognizer:mBackGestureRecognizer];
}
}
- (void)uiStateChanged {
[self setNeedsUpdateOfScreenEdgesDeferringSystemGestures];
[self hideKeyboard];
[self updateGesture];
}
- (void)startVideo:(int)width height:(int)height {
[cameraHelper startVideo:width h:height];
}
- (void)stopVideo {
[cameraHelper stopVideo];
}
- (void)PushCameraImageIOS:(long long)len buffer:(unsigned char*)data {
Camera::pushCameraImage(len, data);
}
- (void)startLocation {
[locationHelper startLocationUpdates];
}
- (void)stopLocation {
[locationHelper stopLocationUpdates];
}
- (void)SetGpsDataIOS:(CLLocation *)newLocation {
GPS::setGpsData((long long)newLocation.timestamp.timeIntervalSince1970,
newLocation.horizontalAccuracy/5.0,
newLocation.coordinate.latitude, newLocation.coordinate.longitude,
newLocation.altitude,
MAX(newLocation.speed * 3.6, 0.0), /* m/s to km/h */
0 /* bearing */);
}
- (void)viewDidLoad {
[super viewDidLoad];
cameraHelper = [[CameraHelper alloc] init];
[cameraHelper setDelegate:self];
locationHelper = [[LocationHelper alloc] init];
[locationHelper setDelegate:self];
self.motionManager = [[CMMotionManager alloc] init];
keyboardInputView_ = [[KeyboardInputView alloc] initWithFrame:CGRectZero];
[self.view addSubview:keyboardInputView_];
}
extern float g_safeInsetLeft;
extern float g_safeInsetRight;
extern float g_safeInsetTop;
extern float g_safeInsetBottom;
- (void)viewSafeAreaInsetsDidChange {
[super viewSafeAreaInsetsDidChange];
// Converts points to pixels, the same way updateResolutionWithView does.
CGFloat scale = [self displayScaleForView:self.view];
float xInsetSum = self.view.safeAreaInsets.left + self.view.safeAreaInsets.right;
float yInsetSum = self.view.safeAreaInsets.top + self.view.safeAreaInsets.bottom;
// Only use the larger set of insets. The other set, we'll handle through layouts.
// Also, we'll treat the bottom inset differently: We'll add some space at the end of everything
// that scrolls so that when you're not at the bottom, we use the full vertical area,
// just looks better.
if (xInsetSum > yInsetSum) {
// Landscape mode insets are larger, use those.
g_safeInsetLeft = self.view.safeAreaInsets.left * scale;
g_safeInsetRight = self.view.safeAreaInsets.right * scale;
g_safeInsetTop = 0.0f;
g_safeInsetBottom = 0.0f;
} else {
// Portrait mode insets are larger, use those.
g_safeInsetLeft = 0.0f;
g_safeInsetRight = 0.0f;
g_safeInsetTop = self.view.safeAreaInsets.top * scale;
g_safeInsetBottom = self.view.safeAreaInsets.bottom * scale;
}
}
- (void)shareText:(NSString *)text {
[self shareItems:@[text]];
}
- (void)shareFile:(NSURL *)url {
// The share sheet works out the type from the extension.
[self shareItems:@[url]];
}
- (void)shareItems:(NSArray *)items {
UIActivityViewController *viewController = [[UIActivityViewController alloc] initWithActivityItems:items applicationActivities:nil];
// On iPad, the share sheet is a popover and needs an anchor.
viewController.popoverPresentationController.sourceView = self.view;
viewController.popoverPresentationController.sourceRect = CGRectMake(CGRectGetMidX(self.view.bounds), CGRectGetMidY(self.view.bounds), 0, 0);
viewController.popoverPresentationController.permittedArrowDirections = 0;
[self presentViewController:viewController animated:YES completion:nil];
}
- (void)appSwitchModeChanged {
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
}
// Main thread, like the rest of these.
- (void)showKeyboard {
INFO_LOG(Log::System, "showKeyboard");
[keyboardInputView_ becomeFirstResponder];
}
- (void)hideKeyboard {
if (keyboardInputView_.isFirstResponder) {
INFO_LOG(Log::System, "hideKeyboard");
[keyboardInputView_ resignFirstResponder];
// Back to us, for hardware keyboard presses. We don't take text, so no keyboard comes with it.
[self becomeFirstResponder];
}
}
- (BOOL)canBecomeFirstResponder {
return YES;
}
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(UIPressesEvent *)event {
KeyboardPressesBegan(presses, event);
}
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(UIPressesEvent *)event {
KeyboardPressesEnded(presses, event);
}
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(UIPressesEvent *)event {
KeyboardPressesEnded(presses, event);
}
#pragma mark - Status Bar Control
// The immersive mode setting is per-orientation, so we need to know which way we're facing.
// Can't just use g_display for this, since it lags behind during rotation.
// Note: Using viewIfLoaded, since this can get called before the view exists, and we don't want to force it into existence.
- (DeviceOrientation)currentDeviceOrientation {
UIWindowScene *scene = self.viewIfLoaded.window.windowScene;
if (scene != nil) {
return UIInterfaceOrientationIsPortrait(scene.interfaceOrientation) ? DeviceOrientation::Portrait : DeviceOrientation::Landscape;
}
CGSize size = self.viewIfLoaded.bounds.size;
return size.height > size.width ? DeviceOrientation::Portrait : DeviceOrientation::Landscape;
}
// iOS calls this to determine whether to hide the status bar.
// Note that on iPhone, iOS hides it in landscape regardless of what we return here.
- (BOOL)prefersStatusBarHidden {
return g_Config.GetDisplayLayoutConfig([self currentDeviceOrientation]).bImmersiveMode ? YES : NO;
}
// Optional: choose light/dark text for the status bar
- (UIStatusBarStyle)preferredStatusBarStyle {
return UIStatusBarStyleLightContent;
}
- (void)onOrientationChanged {
[self setNeedsStatusBarAppearanceUpdate];
}
// Called from the C++ side when the user toggles the immersive mode setting.
- (void)immersiveModeChanged {
[self setNeedsStatusBarAppearanceUpdate];
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
// The immersive mode setting is per-orientation, so the status bar may need to change along with the rotation.
[self setNeedsStatusBarAppearanceUpdate];
}
// Rotations and window resizes land here. UIKit lays out at the final size at the start of a
// rotation animation, so resizing here (rather than when the rotation completes) keeps the
// backbuffer matching the view while it animates.
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
CGSize size = self.view.bounds.size;
if (size.width <= 0.0 || size.height <= 0.0 || CGSizeEqualToSize(size, lastLayoutSize_)) {
return;
}
lastLayoutSize_ = size;
if (self.view.window.windowScene) {
[cameraHelper setInterfaceOrientation:self.view.window.windowScene.interfaceOrientation];
}
INFO_LOG(Log::G3D, "Laid out at %dx%d", (int)size.width, (int)size.height);
// Resized in place: NativeFrame picks up the new size and lets the graphics context resize.
[self updateResolutionWithView:self.view];
}
// Pixels per point. nativeScale, not scale, since that's what the drawable is - they differ on
// some phones, like the Plus models and the minis.
- (CGFloat)displayScaleForView:(UIView *)view {
if (view.window.windowScene) {
return view.window.windowScene.screen.nativeScale;
}
return [UITraitCollection currentTraitCollection].displayScale;
}
- (void)updateResolutionWithView:(UIView *)view {
CGFloat scale = [self displayScaleForView:view];
// The bounds are already corrected for orientation.
CGSize size = view.bounds.size;
float dpi = (IS_IPAD() ? 200.0f : 150.0f) * scale;
const float dpi_scale_x = 240.0f / dpi;
const float dpi_scale_y = 240.0f / dpi;
g_display.Recalculate(size.width * scale, size.height * scale,
dpi_scale_x, dpi_scale_y,
UIScaleFactorToMultiplier(g_Config.iUIScaleFactor));
[view setContentScaleFactor:scale];
// NativeFrame updates the PSP_CoreParameter() size along with the rest.
NativeResized();
NSLog(@"Updated display resolution: (%d, %d) @%.1fx",
g_display.pixel_xres, g_display.pixel_yres, (float)scale);
}
@end