From 2a9d7145be72ee8ce0bdda361654ac3faa01aade Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:44:56 -0600 Subject: [PATCH 1/4] Added role based access to android login - Admin has access to everything - Staff has limited access to what they can edit in listfragment - Customers cannot login to app - added validations to pets, supplier and services in their detailed view --- .../activities/MainActivity.java | 27 ++++++++++++++---- .../fragments/ListFragment.java | 10 ++++++- .../detailfragments/PetDetailFragment.java | 25 +++++++++++------ .../ServiceDetailFragment.java | 28 ++++++++++++------- .../SupplierDetailFragment.java | 26 +++++++++++++---- 5 files changed, 85 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java b/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java index be50fd5a..242000c8 100644 --- a/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java +++ b/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java @@ -39,11 +39,17 @@ public class MainActivity extends AppCompatActivity { super.onCreate(savedInstanceState); // Check if user is already logged in - if (TokenManager.getInstance(this).isLoggedIn()) { - Intent intent = new Intent(this, HomeActivity.class); - startActivity(intent); - finish(); - return; + TokenManager tokenManager = TokenManager.getInstance(this); + if (tokenManager.isLoggedIn()) { + if ("CUSTOMER".equalsIgnoreCase(tokenManager.getRole())) { + // If a customer somehow remained logged in, clear them out + tokenManager.clearLoginData(); + } else { + Intent intent = new Intent(this, HomeActivity.class); + startActivity(intent); + finish(); + return; + } } EdgeToEdge.enable(this); @@ -83,11 +89,20 @@ public class MainActivity extends AppCompatActivity { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful() && response.body() != null) { + String role = response.body().getRole(); + + // Check if the user is a CUSTOMER and deny login if so + if ("CUSTOMER".equalsIgnoreCase(role)) { + Toast.makeText(MainActivity.this, "Access denied: Customers are not allowed to log in.", Toast.LENGTH_LONG).show(); + tvLoginStatus.setText("Customers are not allowed to log in"); + return; + } + //save login data in shared preferences TokenManager.getInstance(MainActivity.this).saveLoginData( response.body().getToken(), response.body().getUsername(), - response.body().getRole() + role ); //fetch user id from api then login to home activity diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java index cd07df63..75d8aacd 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java @@ -14,6 +14,7 @@ import android.widget.LinearLayout; import com.example.petstoremobile.R; +import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.fragments.listfragments.PetFragment; import com.example.petstoremobile.fragments.listfragments.ServiceFragment; import com.example.petstoremobile.fragments.listfragments.SupplierFragment; @@ -49,6 +50,13 @@ public class ListFragment extends Fragment { drawerInventory = view.findViewById(R.id.drawerInventory); drawerProducts = view.findViewById(R.id.drawerProducts); + // Check user role and restrict access for STAFF + String role = TokenManager.getInstance(requireContext()).getRole(); + if ("STAFF".equalsIgnoreCase(role)) { + drawerSuppliers.setVisibility(View.GONE); + drawerInventory.setVisibility(View.GONE); + } + //needed to disable touches on the innerContainer while the drawer is open touchBlocker = view.findViewById(R.id.touchBlocker); @@ -142,4 +150,4 @@ public class ListFragment extends Fragment { .addToBackStack(null) .commit(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java index 6906bc73..70c19833 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java @@ -25,6 +25,8 @@ import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.dtos.PetDTO; import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.listfragments.PetFragment; +import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -65,26 +67,27 @@ public class PetDetailFragment extends Fragment { //Method to Update or Add a pet private void savePet() { + // Validates all fields using InputValidator + if (!InputValidator.isNotEmpty(etPetName, "Pet Name")) return; + if (!InputValidator.isNotEmpty(etPetSpecies, "Species")) return; + if (!InputValidator.isNotEmpty(etPetBreed, "Breed")) return; + if (!InputValidator.isPositiveInteger(etPetAge, "Age")) return; + if (!InputValidator.isPositiveDecimal(etPetPrice, "Price")) return; + //get all the values from the fields String name = etPetName.getText().toString().trim(); String species = etPetSpecies.getText().toString().trim(); String breed = etPetBreed.getText().toString().trim(); - String ageStr = etPetAge.getText().toString().trim(); + int age = Integer.parseInt(etPetAge.getText().toString().trim()); String priceStr = etPetPrice.getText().toString().trim(); String status = spinnerPetStatus.getSelectedItem().toString(); - //check if all the fields are filled - if (name.isEmpty() || species.isEmpty() || breed.isEmpty() || ageStr.isEmpty() || priceStr.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } - //create a pet object to send to the API PetDTO petDTO = new PetDTO(); petDTO.setPetName(name); petDTO.setPetSpecies(species); petDTO.setPetBreed(breed); - petDTO.setPetAge(Integer.parseInt(ageStr)); + petDTO.setPetAge(age); petDTO.setPetPrice(priceStr); petDTO.setPetStatus(status); @@ -98,6 +101,7 @@ public class PetDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Pet", "UPDATED", petId); Toast.makeText(getContext(), "Pet updated successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -107,6 +111,7 @@ public class PetDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "PetDetailFragment.updatePet", new Exception(t)); Log.e("PetDetailFragment", "Error updating pet", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -117,6 +122,7 @@ public class PetDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.log(requireContext(), "Added new Pet: " + name); Toast.makeText(getContext(), "Pet added successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -126,6 +132,7 @@ public class PetDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "PetDetailFragment.createPet", new Exception(t)); Log.e("PetDetailFragment", "Error adding pet", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -146,6 +153,7 @@ public class PetDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Pet", "DELETED", petId); Toast.makeText(getContext(), "Pet deleted successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -155,6 +163,7 @@ public class PetDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "PetDetailFragment.deletePet", new Exception(t)); Log.e("PetDetailFragment", "Error deleting pet", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java index 2defbb69..c7b0a4c5 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java @@ -20,6 +20,8 @@ import com.example.petstoremobile.api.ServiceApi; import com.example.petstoremobile.dtos.ServiceDTO; import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.listfragments.ServiceFragment; +import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -58,24 +60,24 @@ public class ServiceDetailFragment extends Fragment { //Method to Update or Add a service private void saveService() { + // Validates all fields using InputValidator + if (!InputValidator.isNotEmpty(etServiceName, "Service Name")) return; + if (!InputValidator.isNotEmpty(etServiceDesc, "Description")) return; + if (!InputValidator.isPositiveInteger(etServiceDuration, "Duration")) return; + if (!InputValidator.isPositiveDecimal(etServicePrice, "Price")) return; + //get all the values from the fields String name = etServiceName.getText().toString().trim(); String desc = etServiceDesc.getText().toString().trim(); - String durationStr = etServiceDuration.getText().toString().trim(); - String priceStr = etServicePrice.getText().toString().trim(); - - //check if all the fields are filled (desc is optional) - if (name.isEmpty() || durationStr.isEmpty() || priceStr.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } + int duration = Integer.parseInt(etServiceDuration.getText().toString().trim()); + double price = Double.parseDouble(etServicePrice.getText().toString().trim()); //create a service object to send to the API ServiceDTO serviceDTO = new ServiceDTO(); serviceDTO.setServiceName(name); serviceDTO.setServiceDesc(desc); - serviceDTO.setServiceDuration(Integer.parseInt(durationStr)); - serviceDTO.setServicePrice(Double.parseDouble(priceStr)); + serviceDTO.setServiceDuration(duration); + serviceDTO.setServicePrice(price); ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext()); @@ -87,6 +89,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Service", "UPDATED", serviceId); Toast.makeText(getContext(), "Service updated successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -96,6 +99,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "ServiceDetailFragment.updateService", new Exception(t)); Log.e("ServiceDetailFragment", "Error updating service", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -106,6 +110,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.log(requireContext(), "Added new Service: " + name); Toast.makeText(getContext(), "Service added successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -115,6 +120,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "ServiceDetailFragment.createService", new Exception(t)); Log.e("ServiceDetailFragment", "Error adding service", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -134,6 +140,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Service", "DELETED", serviceId); Toast.makeText(getContext(), "Service deleted successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -143,6 +150,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "ServiceDetailFragment.deleteService", new Exception(t)); Log.e("ServiceDetailFragment", "Error deleting service", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java index 8537d6c2..df5c5520 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java @@ -20,6 +20,8 @@ import com.example.petstoremobile.api.SupplierApi; import com.example.petstoremobile.dtos.SupplierDTO; import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.listfragments.SupplierFragment; +import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -58,6 +60,13 @@ public class SupplierDetailFragment extends Fragment { //Method to Update or Add a supplier private void saveSupplier() { + // Validates all fields using InputValidator + if (!InputValidator.isNotEmpty(etSupCompany, "Company Name")) return; + if (!InputValidator.isNotEmpty(etSupContactFirstName, "First Name")) return; + if (!InputValidator.isNotEmpty(etSupContactLastName, "Last Name")) return; + if (!InputValidator.isValidEmail(etSupEmail)) return; + if (!InputValidator.isValidPhone(etSupPhone)) return; + //get all the values from the fields String company = etSupCompany.getText().toString().trim(); String firstName = etSupContactFirstName.getText().toString().trim(); @@ -65,12 +74,6 @@ public class SupplierDetailFragment extends Fragment { String email = etSupEmail.getText().toString().trim(); String phone = etSupPhone.getText().toString().trim(); - //check if all the fields are filled - if (company.isEmpty() || firstName.isEmpty() || lastName.isEmpty() || email.isEmpty() || phone.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } - //create a supplier object to send to the API SupplierDTO supplierDTO = new SupplierDTO(); supplierDTO.setSupCompany(company); @@ -89,6 +92,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Supplier", "UPDATED", supId); Toast.makeText(getContext(), "Supplier updated successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -98,6 +102,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "SupplierDetailFragment.updateSupplier", new Exception(t)); Log.e("SupplierDetailFragment", "Error updating supplier", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -108,6 +113,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.log(requireContext(), "Added new Supplier: " + company); Toast.makeText(getContext(), "Supplier added successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -117,6 +123,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "SupplierDetailFragment.createSupplier", new Exception(t)); Log.e("SupplierDetailFragment", "Error adding supplier", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -136,6 +143,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Supplier", "DELETED", supId); Toast.makeText(getContext(), "Supplier deleted successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -145,6 +153,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "SupplierDetailFragment.deleteSupplier", new Exception(t)); Log.e("SupplierDetailFragment", "Error deleting supplier", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -197,6 +206,11 @@ public class SupplierDetailFragment extends Fragment { etSupContactLastName = view.findViewById(R.id.etSupContactLastName); etSupEmail = view.findViewById(R.id.etSupEmail); etSupPhone = view.findViewById(R.id.etSupPhone); + + // Add phone number formatting (CA) and limit length to 14 characters + etSupPhone.addTextChangedListener(new android.telephony.PhoneNumberFormattingTextWatcher("CA")); + etSupPhone.setFilters(new android.text.InputFilter[]{new android.text.InputFilter.LengthFilter(14)}); + btnSaveSupplier = view.findViewById(R.id.btnSaveSupplier); btnDeleteSupplier = view.findViewById(R.id.btnDeleteSupplier); btnBack = view.findViewById(R.id.btnBack); -- 2.49.1 From 4659aa44df2a2e1c885d398d0a15f6eeffbbae6c Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 25 Mar 2026 22:51:29 -0600 Subject: [PATCH 2/4] readd secure avatar endpoints --- android/.gitignore | 2 + android/app/.gitignore | 3 + backend/petshop-api.postman_collection.json | 75 ++++++++++++- .../config/FlywayContextInitializer.java | 38 +++++-- .../backend/controller/AuthController.java | 65 +++++------ .../controller/UserAvatarController.java | 43 +++++++ .../backend/service/AvatarStorageService.java | 105 ++++++++++++++++++ .../example/petshopdesktop/api/ApiClient.java | 25 +++++ .../petshopdesktop/api/endpoints/AuthApi.java | 4 + .../controllers/MainLayoutController.java | 47 ++++---- 10 files changed, 343 insertions(+), 64 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/controller/UserAvatarController.java create mode 100644 backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java diff --git a/android/.gitignore b/android/.gitignore index f7930e52..5cfb3b8d 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -16,6 +16,8 @@ /app/src/androidTest/ /app/src/test/ .DS_Store +/.project +/.settings/ /build /captures .externalNativeBuild diff --git a/android/app/.gitignore b/android/app/.gitignore index 12b09d99..86de5a5e 100644 --- a/android/app/.gitignore +++ b/android/app/.gitignore @@ -1,3 +1,6 @@ /build +/.classpath +/.project +/.settings/ /src/test/ /src/androidTest/ diff --git a/backend/petshop-api.postman_collection.json b/backend/petshop-api.postman_collection.json index d95a5b86..0d59e4ae 100644 --- a/backend/petshop-api.postman_collection.json +++ b/backend/petshop-api.postman_collection.json @@ -90,6 +90,10 @@ "key": "avatarFile", "value": "postman/avatar.png" }, + { + "key": "avatarUrl", + "value": "" + }, { "key": "bulkPetId", "value": "" @@ -212,6 +216,7 @@ " pm.response.to.have.status(200);", "});", "var jsonData = pm.response.json();", + "if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);", "if (jsonData.token) pm.collectionVariables.set('customerToken', jsonData.token);" ] } @@ -307,7 +312,9 @@ "exec": [ "pm.test('Status code is 200', function () {", " pm.response.to.have.status(200);", - "});" + "});", + "var jsonData = pm.response.json();", + "if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);" ] } } @@ -381,7 +388,8 @@ " pm.response.to.have.status(200);", "});", "var jsonData = pm.response.json();", - "pm.expect(jsonData.avatarUrl).to.be.a('string');" + "pm.expect(jsonData.avatarUrl).to.be.a('string');", + "pm.collectionVariables.set('avatarUrl', jsonData.avatarUrl);" ] } } @@ -414,7 +422,68 @@ " pm.response.to.have.status(200);", "});", "var jsonData = pm.response.json();", - "pm.expect(jsonData.avatarUrl).to.be.a('string');" + "pm.expect(jsonData.avatarUrl).to.be.a('string');", + "pm.collectionVariables.set('avatarUrl', jsonData.avatarUrl);" + ] + } + } + ] + }, + { + "name": "Get My Avatar File", + "request": { + "method": "GET", + "url": "{{baseUrl}}{{avatarUrl}}", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{customerToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Avatar response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Get User Avatar File As Staff", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/users/{{userId}}/avatar/file", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Avatar response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" ] } } diff --git a/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java b/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java index 3fb28d10..000ebe86 100644 --- a/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java +++ b/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java @@ -10,6 +10,9 @@ import java.util.Arrays; public class FlywayContextInitializer implements ApplicationContextInitializer { + private static final int MAX_RETRIES = 15; + private static final long RETRY_DELAY_MILLIS = 1000L; + @Override public void initialize(ConfigurableApplicationContext applicationContext) { ConfigurableEnvironment environment = applicationContext.getEnvironment(); @@ -29,12 +32,33 @@ public class FlywayContextInitializer implements ApplicationContextInitializer !location.isEmpty()) .toArray(String[]::new); - Flyway.configure() - .dataSource(url, username, password) - .locations(locations) - .baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false)) - .baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1"))) - .load() - .migrate(); + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + Flyway.configure() + .dataSource(url, username, password) + .locations(locations) + .baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false)) + .baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1"))) + .load() + .migrate(); + return; + } catch (RuntimeException ex) { + lastFailure = ex; + if (attempt == MAX_RETRIES) { + throw ex; + } + try { + Thread.sleep(RETRY_DELAY_MILLIS); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for database startup", interruptedException); + } + } + } + + if (lastFailure != null) { + throw lastFailure; + } } } diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index 2bd2b47d..b426aadc 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -13,10 +13,13 @@ import com.petshop.backend.repository.EmployeeRepository; import com.petshop.backend.repository.EmployeeStoreRepository; import com.petshop.backend.repository.UserRepository; import com.petshop.backend.security.JwtUtil; +import com.petshop.backend.service.AvatarStorageService; import com.petshop.backend.service.UserBusinessLinkageService; import com.petshop.backend.util.AuthenticationHelper; import jakarta.validation.Valid; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -28,15 +31,9 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.Map; -import java.util.UUID; @RestController @RequestMapping("/api/v1/auth") @@ -49,8 +46,9 @@ public class AuthController { private final UserBusinessLinkageService userBusinessLinkageService; private final EmployeeRepository employeeRepository; private final EmployeeStoreRepository employeeStoreRepository; + private final AvatarStorageService avatarStorageService; - public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository) { + public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository, AvatarStorageService avatarStorageService) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.jwtUtil = jwtUtil; @@ -58,6 +56,7 @@ public class AuthController { this.userBusinessLinkageService = userBusinessLinkageService; this.employeeRepository = employeeRepository; this.employeeStoreRepository = employeeStoreRepository; + this.avatarStorageService = avatarStorageService; } @PostMapping("/register") @@ -155,7 +154,7 @@ public class AuthController { user.getEmail(), user.getFullName(), user.getPhone(), - user.getAvatarUrl(), + avatarStorageService.toOwnerAvatarUrl(user), user.getRole().name(), employeeStore != null ? employeeStore.getStore().getStoreId() : null, employeeStore != null ? employeeStore.getStore().getStoreName() : null @@ -224,7 +223,7 @@ public class AuthController { updatedUser.getEmail(), updatedUser.getFullName(), updatedUser.getPhone(), - updatedUser.getAvatarUrl(), + avatarStorageService.toOwnerAvatarUrl(updatedUser), updatedUser.getRole().name(), employeeStore != null ? employeeStore.getStore().getStoreId() : null, employeeStore != null ? employeeStore.getStore().getStoreName() : null @@ -273,26 +272,12 @@ public class AuthController { } try { - String uploadDir = "uploads/avatars"; - File directory = new File(uploadDir); - if (!directory.exists()) { - directory.mkdirs(); - } - - String originalFilename = file.getOriginalFilename(); - String extension = originalFilename != null && originalFilename.contains(".") - ? originalFilename.substring(originalFilename.lastIndexOf(".")) - : ".jpg"; - String filename = UUID.randomUUID().toString() + extension; - Path filePath = Paths.get(uploadDir, filename); - - Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); - - String avatarUrl = "/uploads/avatars/" + filename; - user.setAvatarUrl(avatarUrl); + avatarStorageService.deleteAvatar(user); + String avatarPath = avatarStorageService.storeAvatar(file); + user.setAvatarUrl(avatarPath); userRepository.save(user); - return ResponseEntity.ok(new AvatarUploadResponse(avatarUrl, "Avatar uploaded successfully")); + return ResponseEntity.ok(new AvatarUploadResponse(avatarStorageService.toOwnerAvatarUrl(user), "Avatar uploaded successfully")); } catch (IOException e) { Map error = new HashMap<>(); @@ -305,25 +290,41 @@ public class AuthController { public ResponseEntity getAvatar() { User user = getAuthenticatedUser(); - if (user.getAvatarUrl() == null || user.getAvatarUrl().isEmpty()) { + if (!avatarStorageService.hasAvatar(user)) { Map error = new HashMap<>(); error.put("message", "No avatar uploaded"); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } Map response = new HashMap<>(); - response.put("avatarUrl", user.getAvatarUrl()); + response.put("avatarUrl", avatarStorageService.toOwnerAvatarUrl(user)); return ResponseEntity.ok(response); } + @GetMapping("/me/avatar/file") + public ResponseEntity getAvatarFile() { + User user = getAuthenticatedUser(); + + if (!avatarStorageService.hasAvatar(user)) { + return ResponseEntity.notFound().build(); + } + + try { + Resource resource = avatarStorageService.loadAvatarResource(user); + MediaType mediaType = avatarStorageService.resolveMediaType(user); + return ResponseEntity.ok().contentType(mediaType).body(resource); + } catch (IllegalArgumentException ex) { + return ResponseEntity.notFound().build(); + } + } + @DeleteMapping("/me/avatar") public ResponseEntity deleteAvatar() { User user = getAuthenticatedUser(); - if (user.getAvatarUrl() != null && !user.getAvatarUrl().isEmpty()) { + if (avatarStorageService.hasAvatar(user)) { try { - Path filePath = Paths.get("." + user.getAvatarUrl()); - Files.deleteIfExists(filePath); + avatarStorageService.deleteAvatar(user); } catch (IOException e) { } user.setAvatarUrl(null); diff --git a/backend/src/main/java/com/petshop/backend/controller/UserAvatarController.java b/backend/src/main/java/com/petshop/backend/controller/UserAvatarController.java new file mode 100644 index 00000000..bb5c9342 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/UserAvatarController.java @@ -0,0 +1,43 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.service.AvatarStorageService; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users") +public class UserAvatarController { + + private final UserRepository userRepository; + private final AvatarStorageService avatarStorageService; + + public UserAvatarController(UserRepository userRepository, AvatarStorageService avatarStorageService) { + this.userRepository = userRepository; + this.avatarStorageService = avatarStorageService; + } + + @GetMapping("/{userId}/avatar/file") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity getUserAvatarFile(@PathVariable Long userId) { + User user = userRepository.findById(userId).orElse(null); + if (user == null || !avatarStorageService.hasAvatar(user)) { + return ResponseEntity.notFound().build(); + } + + try { + Resource resource = avatarStorageService.loadAvatarResource(user); + return ResponseEntity.ok() + .contentType(avatarStorageService.resolveMediaType(user)) + .body(resource); + } catch (IllegalArgumentException ex) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java b/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java new file mode 100644 index 00000000..952ca600 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java @@ -0,0 +1,105 @@ +package com.petshop.backend.service; + +import com.petshop.backend.entity.User; +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.UUID; + +@Service +public class AvatarStorageService { + + private static final String STORED_PREFIX = "/uploads/avatars/"; + private static final String OWNER_ENDPOINT = "/api/v1/auth/me/avatar/file"; + + private final Path avatarDirectory = Paths.get("uploads", "avatars").toAbsolutePath().normalize(); + + public String storeAvatar(MultipartFile file) throws IOException { + Files.createDirectories(avatarDirectory); + + String originalFilename = file.getOriginalFilename(); + String extension = resolveExtension(originalFilename); + String filename = UUID.randomUUID() + extension; + Path filePath = avatarDirectory.resolve(filename).normalize(); + + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + return STORED_PREFIX + filename; + } + + public Resource loadAvatarResource(User user) { + Path filePath = resolveStoredAvatarPath(user.getAvatarUrl()); + if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { + throw new IllegalArgumentException("Avatar file was not found"); + } + return new PathResource(filePath); + } + + public void deleteAvatar(User user) throws IOException { + if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) { + return; + } + Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl())); + } + + public String toOwnerAvatarUrl(User user) { + return hasAvatar(user) ? OWNER_ENDPOINT : null; + } + + public String toStoredAvatarUrl(String avatarFilenamePath) { + return avatarFilenamePath; + } + + public boolean hasAvatar(User user) { + return user.getAvatarUrl() != null && !user.getAvatarUrl().isBlank(); + } + + public MediaType resolveMediaType(User user) { + try { + return MediaTypeFactory.getMediaType(loadAvatarResource(user)).orElse(MediaType.APPLICATION_OCTET_STREAM); + } catch (IllegalArgumentException ex) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } + + private Path resolveStoredAvatarPath(String storedAvatarUrl) { + if (storedAvatarUrl == null || storedAvatarUrl.isBlank() || !storedAvatarUrl.startsWith(STORED_PREFIX)) { + throw new IllegalArgumentException("Avatar file was not found"); + } + + String filename = storedAvatarUrl.substring(STORED_PREFIX.length()); + if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) { + throw new IllegalArgumentException("Avatar file was not found"); + } + + Path resolved = avatarDirectory.resolve(filename).normalize(); + if (!resolved.startsWith(avatarDirectory)) { + throw new IllegalArgumentException("Avatar file was not found"); + } + return resolved; + } + + private String resolveExtension(String originalFilename) { + if (originalFilename == null) { + return ".jpg"; + } + int extensionIndex = originalFilename.lastIndexOf('.'); + if (extensionIndex < 0 || extensionIndex == originalFilename.length() - 1) { + return ".jpg"; + } + String extension = originalFilename.substring(extensionIndex).toLowerCase(Locale.ROOT); + return switch (extension) { + case ".jpg", ".jpeg", ".png", ".gif" -> extension; + default -> ".jpg"; + }; + } +} diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/ApiClient.java b/desktop/src/main/java/org/example/petshopdesktop/api/ApiClient.java index c0fbd874..ed2e9fc6 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/ApiClient.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/ApiClient.java @@ -48,6 +48,31 @@ public class ApiClient { return handleResponse(response, responseClass); } + public byte[] getBytes(String path) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .GET() + .timeout(Duration.ofSeconds(30)); + + addAuthHeader(builder); + + HttpRequest request = builder.build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + int statusCode = response.statusCode(); + if (statusCode == 200 || statusCode == 201) { + return response.body(); + } else if (statusCode == 401) { + throw new RuntimeException("Authentication failed. Please log in again."); + } else if (statusCode == 403) { + throw new RuntimeException("Access restricted. You don't have permission to perform this action."); + } else if (statusCode == 404) { + throw new RuntimeException("Avatar not found."); + } else { + throw new RuntimeException("Request failed with status " + statusCode); + } + } + public String getRawResponse(String path) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(baseUrl + path)) diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java index a273a738..0755ef9e 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java @@ -26,6 +26,10 @@ public class AuthApi { return apiClient.postMultipart("/api/v1/auth/me/avatar", "avatar", filePath, AvatarUploadResponse.class); } + public byte[] getMyAvatarFile() throws Exception { + return apiClient.getBytes("/api/v1/auth/me/avatar/file"); + } + public void deleteAvatar() throws Exception { apiClient.delete("/api/v1/auth/me/avatar"); } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index b262be0b..cb283dd2 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -18,7 +18,6 @@ import javafx.scene.paint.ImagePattern; import javafx.scene.shape.Circle; import javafx.stage.FileChooser; import javafx.stage.Stage; -import org.example.petshopdesktop.api.ApiConfig; import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse; import org.example.petshopdesktop.api.dto.auth.UserInfoResponse; @@ -27,6 +26,8 @@ import org.example.petshopdesktop.auth.UserSession; import org.example.petshopdesktop.ui.SvgWebViewFactory; import org.example.petshopdesktop.util.ActivityLogger; +import java.io.ByteArrayInputStream; + public class MainLayoutController { private static final String NAV_BASE_STYLE = "-fx-background-color: transparent; " + @@ -218,8 +219,7 @@ public class MainLayoutController { try { AvatarUploadResponse response = AuthApi.getInstance().uploadAvatar(file.toPath()); UserSession.getInstance().setAvatarUrl(response.getAvatarUrl()); - renderAvatar(UserSession.getInstance().getEmployeeName(), response.getAvatarUrl()); - btnRemoveAvatar.setDisable(response.getAvatarUrl() == null || response.getAvatarUrl().isBlank()); + refreshProfileHeader(); } catch (Exception e) { ActivityLogger.getInstance().logException("MainLayoutController.btnChangeAvatarClicked", e, "Uploading avatar"); showAvatarError(e.getMessage() != null ? e.getMessage() : "Could not upload profile picture."); @@ -263,7 +263,7 @@ public class MainLayoutController { @FXML public void initialize() { logoContainer.getChildren().setAll(SvgWebViewFactory.build("/org/example/petshopdesktop/images/leons-pet-store-badge-light.svg", 94)); - renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl()); + renderAvatar(UserSession.getInstance().getEmployeeName(), null); btnRemoveAvatar.setDisable(UserSession.getInstance().getAvatarUrl() == null || UserSession.getInstance().getAvatarUrl().isBlank()); refreshProfileHeader(); applyRBAC(); @@ -285,20 +285,35 @@ public class MainLayoutController { String displayName = userInfo.getFullName() == null || userInfo.getFullName().isBlank() ? UserSession.getInstance().getUsername() : userInfo.getFullName(); + Image avatarImage = loadAvatarImage(userInfo.getAvatarUrl()); Platform.runLater(() -> { UserSession.getInstance().setEmployeeName(displayName); UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl()); lblUsername.setText(displayName); - renderAvatar(displayName, userInfo.getAvatarUrl()); + renderAvatar(displayName, avatarImage); btnRemoveAvatar.setDisable(userInfo.getAvatarUrl() == null || userInfo.getAvatarUrl().isBlank()); }); } catch (Exception e) { - Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl())); + Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), null)); } }).start(); } - private void renderAvatar(String displayName, String avatarUrl) { + private Image loadAvatarImage(String avatarUrl) { + if (avatarUrl == null || avatarUrl.isBlank()) { + return null; + } + + try { + byte[] imageBytes = AuthApi.getInstance().getMyAvatarFile(); + Image image = new Image(new ByteArrayInputStream(imageBytes), 52, 52, true, true); + return image.isError() ? null : image; + } catch (Exception e) { + return null; + } + } + + private void renderAvatar(String displayName, Image avatarImage) { Circle border = new Circle(29); border.setFill(Color.web("#dbe4ee")); @@ -306,21 +321,9 @@ public class MainLayoutController { Label initials = new Label(initials(displayName)); initials.setStyle("-fx-text-fill: white; -fx-font-weight: bold; -fx-font-size: 16px;"); - if (avatarUrl != null && !avatarUrl.isBlank()) { - try { - String resolvedUrl = avatarUrl.startsWith("http") ? avatarUrl : ApiConfig.getInstance().getBaseUrl() + avatarUrl; - Image image = new Image(resolvedUrl, 52, 52, true, true, true); - if (!image.isError()) { - circle.setFill(new ImagePattern(image)); - initials.setVisible(false); - } else { - circle.setFill(Color.web("#4ECDC4")); - initials.setVisible(true); - } - } catch (Exception e) { - circle.setFill(Color.web("#4ECDC4")); - initials.setVisible(true); - } + if (avatarImage != null) { + circle.setFill(new ImagePattern(avatarImage)); + initials.setVisible(false); } else { circle.setFill(Color.web("#4ECDC4")); initials.setVisible(true); -- 2.49.1 From 5477c4beee84968074e61630cd29f97df62e840f Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 25 Mar 2026 23:55:40 -0600 Subject: [PATCH 3/4] use swing picker on wayland --- android/.gitignore | 1 + android/app/.gitignore | 1 + backend/.gitignore | 1 + desktop/.gitignore | 1 + desktop/src/main/java/module-info.java | 1 + .../controllers/MainLayoutController.java | 9 +-- .../util/FilePickerSupport.java | 77 +++++++++++++++++++ web/.gitignore | 1 + 8 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java diff --git a/android/.gitignore b/android/.gitignore index 5cfb3b8d..edc74b8b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,4 +1,5 @@ *.iml +nohup.out .gradle /local.properties /.idea/* diff --git a/android/app/.gitignore b/android/app/.gitignore index 86de5a5e..fdcdf404 100644 --- a/android/app/.gitignore +++ b/android/app/.gitignore @@ -1,4 +1,5 @@ /build +/nohup.out /.classpath /.project /.settings/ diff --git a/backend/.gitignore b/backend/.gitignore index 4ade3c30..9aece802 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,5 @@ target/ +nohup.out !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/desktop/.gitignore b/desktop/.gitignore index c5df67b2..2dc38ee8 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -1,4 +1,5 @@ target/ +nohup.out !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/desktop/src/main/java/module-info.java b/desktop/src/main/java/module-info.java index 910be91c..d6cefd63 100644 --- a/desktop/src/main/java/module-info.java +++ b/desktop/src/main/java/module-info.java @@ -2,6 +2,7 @@ module org.example.petshopdesktop { requires javafx.controls; requires javafx.fxml; requires javafx.web; + requires java.desktop; requires java.sql; requires java.net.http; requires com.fasterxml.jackson.databind; diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index cb283dd2..d87e181d 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -16,13 +16,13 @@ import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.ImagePattern; import javafx.scene.shape.Circle; -import javafx.stage.FileChooser; import javafx.stage.Stage; import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse; import org.example.petshopdesktop.api.dto.auth.UserInfoResponse; import org.example.petshopdesktop.api.endpoints.AuthApi; import org.example.petshopdesktop.auth.UserSession; +import org.example.petshopdesktop.util.FilePickerSupport; import org.example.petshopdesktop.ui.SvgWebViewFactory; import org.example.petshopdesktop.util.ActivityLogger; @@ -206,12 +206,7 @@ public class MainLayoutController { @FXML void btnChangeAvatarClicked(ActionEvent event) { - FileChooser chooser = new FileChooser(); - chooser.setTitle("Choose Profile Picture"); - chooser.getExtensionFilters().addAll( - new FileChooser.ExtensionFilter("Image Files", "*.png", "*.jpg", "*.jpeg", "*.gif") - ); - java.io.File file = chooser.showOpenDialog(btnChangeAvatar.getScene().getWindow()); + java.io.File file = FilePickerSupport.pickImageFile(btnChangeAvatar.getScene().getWindow()); if (file == null) { return; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java b/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java new file mode 100644 index 00000000..313c78e9 --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java @@ -0,0 +1,77 @@ +package org.example.petshopdesktop.util; + +import javafx.stage.FileChooser; +import javafx.stage.Window; + +import javax.swing.JFileChooser; +import javax.swing.UIManager; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.Component; +import java.awt.GraphicsEnvironment; +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.atomic.AtomicReference; + +public final class FilePickerSupport { + + private FilePickerSupport() { + } + + public static File pickImageFile(Window ownerWindow) { + if (shouldUseAwtPicker()) { + return pickImageFileWithSwing(); + } + return pickImageFileWithJavaFx(ownerWindow); + } + + private static boolean shouldUseAwtPicker() { + if (GraphicsEnvironment.isHeadless()) { + return false; + } + String sessionType = System.getenv("XDG_SESSION_TYPE"); + String waylandDisplay = System.getenv("WAYLAND_DISPLAY"); + return "wayland".equalsIgnoreCase(sessionType) || (waylandDisplay != null && !waylandDisplay.isBlank()); + } + + private static File pickImageFileWithJavaFx(Window ownerWindow) { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Choose Profile Picture"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Image Files", "*.png", "*.jpg", "*.jpeg", "*.gif")); + return chooser.showOpenDialog(ownerWindow); + } + + private static File pickImageFileWithSwing() { + AtomicReference selectedFile = new AtomicReference<>(); + Runnable dialogTask = () -> { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception ignored) { + } + + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Choose Profile Picture"); + chooser.setAcceptAllFileFilterUsed(false); + chooser.setFileFilter(new FileNameExtensionFilter("Image Files", "png", "jpg", "jpeg", "gif")); + + int result = chooser.showOpenDialog((Component) null); + if (result == JFileChooser.APPROVE_OPTION) { + selectedFile.set(chooser.getSelectedFile()); + } + }; + + try { + if (java.awt.EventQueue.isDispatchThread()) { + dialogTask.run(); + } else { + java.awt.EventQueue.invokeAndWait(dialogTask); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } catch (InvocationTargetException ex) { + throw new IllegalStateException("Failed to open Swing file picker", ex.getCause()); + } + + return selectedFile.get(); + } +} diff --git a/web/.gitignore b/web/.gitignore index 5ef6a520..d957bcc5 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -25,6 +25,7 @@ *.pem # debug +nohup.out npm-debug.log* yarn-debug.log* yarn-error.log* -- 2.49.1 From 39f912f711dcbcf799528ad398229de3c64ab7c4 Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Thu, 26 Mar 2026 00:47:47 -0600 Subject: [PATCH 4/4] Added profile photo loading and uploading - profile photos now load from backend - profile photos can be uploaded to the backend - RetrofitClient now automatically determines if the device is an emulator or hardware so we dont have to comment and uncomment everytime we test with a different device --- android/app/build.gradle.kts | 3 + .../petstoremobile/api/RetrofitClient.java | 23 +++- .../petstoremobile/api/auth/AuthApi.java | 11 ++ .../fragments/ProfileFragment.java | 100 ++++++++++++++++-- 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d1cc3c30..fa8c88a7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -56,6 +56,9 @@ dependencies { implementation("io.reactivex.rxjava2:rxjava:2.2.21") implementation("io.reactivex.rxjava2:rxandroid:2.1.1") + implementation("com.github.bumptech.glide:glide:4.16.0") + annotationProcessor("com.github.bumptech.glide:compiler:4.16.0") + testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java b/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java index 8a7f48bc..1500315f 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java @@ -1,6 +1,7 @@ package com.example.petstoremobile.api; import android.content.Context; +import android.os.Build; import com.example.petstoremobile.api.auth.AuthApi; import com.example.petstoremobile.api.auth.AuthInterceptor; @@ -12,9 +13,23 @@ import retrofit2.converter.gson.GsonConverterFactory; //Retrofit client Used for API calls public class RetrofitClient { - //base URL - public static final String BASE_URL = "http://10.0.2.2:8080"; //for emulator testing -// public static final String BASE_URL = "http://10.0.0.200:8080/"; //for hardware testing + public static final String BASE_URL = getBaseUrl(); + + // Helper function to determine BASE_URL based on whether we are testing on an emulator or a real device + private static String getBaseUrl() { + if (Build.FINGERPRINT.contains("generic") + || Build.FINGERPRINT.contains("unknown") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || "google_sdk".equals(Build.PRODUCT)) { + return "http://10.0.2.2:8080/"; //emulator testing + } else { + return "http://10.0.0.200:8080/"; //Hardware testing + } + } private static Retrofit retrofit = null; @@ -67,4 +82,4 @@ public class RetrofitClient { return getClient(context).create(MessageApi.class); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java index a60fc536..75605083 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java @@ -5,15 +5,21 @@ import com.example.petstoremobile.dtos.UserDTO; import java.util.Map; +import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.GET; +import retrofit2.http.Multipart; import retrofit2.http.POST; import retrofit2.http.PUT; +import retrofit2.http.Part; //Api for logging in and getting current user public interface AuthApi { + // endpoint for downloading the current user's avatar file + String AVATAR_FILE_PATH = "api/v1/auth/me/avatar/file"; + //login endpoint @POST("api/v1/auth/login") Call login(@Body AuthDTO.LoginRequest loginRequest); @@ -26,4 +32,9 @@ public interface AuthApi { @PUT("api/v1/auth/me") Call updateMe(@Body Map updates); + //upload avatar endpoint + @Multipart + @POST("api/v1/auth/me/avatar") + Call uploadAvatar(@Part MultipartBody.Part avatar); + } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java index e754b7fa..c7253c70 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java @@ -26,6 +26,10 @@ import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.LazyHeaders; import com.example.petstoremobile.R; import com.example.petstoremobile.activities.MainActivity; import com.example.petstoremobile.api.RetrofitClient; @@ -38,9 +42,14 @@ import com.example.petstoremobile.utils.InputValidator; import com.google.gson.Gson; import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; import java.util.HashMap; import java.util.Map; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -73,8 +82,7 @@ public class ProfileFragment extends Fragment { && result.getData() != null) { //get the selected image and set the image to the profile Uri selectedImage = result.getData().getData(); - imgProfile.setImageURI(selectedImage); - //TODO: SAVE CHANGED PHOTO TO DATABASE + uploadAvatar(selectedImage); } } ); @@ -86,10 +94,7 @@ public class ProfileFragment extends Fragment { success -> { //if a photo is taken set the image profile to it otherwise do nothing if (success) { - //Clear the old image and set the new one - imgProfile.setImageURI(null); - imgProfile.setImageURI(photoUri); - //TODO: SAVE CHANGED PHOTO TO DATABASE + uploadAvatar(photoUri); } } ); @@ -167,7 +172,6 @@ public class ProfileFragment extends Fragment { } }) .show(); - //TODO: UPDATE PHOTO IN DATABASE }); //Edit email button @@ -272,7 +276,31 @@ public class ProfileFragment extends Fragment { tvProfileEmail.setText(currentUser.getEmail()); tvProfilePhone.setText(currentUser.getPhone()); tvProfileRole.setText(currentUser.getRole()); - //TODO: LOAD PHOTO FROM DATABASE + + // get the avatar endpoint to load profile image and the token for authorization + String avatarUrl = RetrofitClient.BASE_URL + AuthApi.AVATAR_FILE_PATH; + String token = TokenManager.getInstance(requireContext()).getToken(); + + if (token != null) { + // Create GlideUrl with token to fetch the image + GlideUrl glideUrl = new GlideUrl(avatarUrl, new LazyHeaders.Builder() + .addHeader("Authorization", "Bearer " + token) + .build()); + + // Load image using Glide + Glide.with(ProfileFragment.this) + .load(glideUrl) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .placeholder(R.drawable.placeholder) + .error(R.drawable.placeholder) + .into(imgProfile); + } else { + // load placeholder image if token is null + Glide.with(ProfileFragment.this) + .load(R.drawable.placeholder) + .into(imgProfile); + } } else { Log.e("onResponse: ", response.message()); @@ -288,6 +316,62 @@ public class ProfileFragment extends Fragment { }); } + //Helper function to call the backend to upload a profile image + private void uploadAvatar(Uri uri) { + try { + File file = getFileFromUri(uri); + if (file == null) return; + + // Create RequestBody for file upload + RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri))); + MultipartBody.Part body = MultipartBody.Part.createFormData("avatar", file.getName(), requestFile); + + //Call the backend to upload the avatar + AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); + authApi.uploadAvatar(body).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful() && response.body() != null) { + currentUser = response.body(); + Toast.makeText(requireContext(), "Avatar updated successfully", Toast.LENGTH_SHORT).show(); + // Reload image after successful upload + loadProfileData(); + } else { + Toast.makeText(requireContext(), "Failed to upload avatar", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.e("UPLOAD_AVATAR", "Failure: " + t.getMessage()); + Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); + } + }); + } catch (Exception e) { + Log.e("UPLOAD_AVATAR", "Error: " + e.getMessage()); + } + } + + // Helper function to create a temporary File object from a Uri for uploading the avatar + private File getFileFromUri(Uri uri) { + try { + InputStream inputStream = requireContext().getContentResolver().openInputStream(uri); + File tempFile = new File(requireContext().getCacheDir(), "upload_avatar.jpg"); + FileOutputStream outputStream = new FileOutputStream(tempFile); + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + outputStream.close(); + inputStream.close(); + return tempFile; + } catch (Exception e) { + Log.e("FILE_UTILS", "Error creating temp file", e); + return null; + } + } + //Helper function to update a profile field in the backend private void updateProfileField(String fieldName, String value) { AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); -- 2.49.1