feat: architecture refactor — core layer, spreadsheet engine, envelope bidding, tests
CI / quality (push) Waiting to run

- Add src/core/ (config, errors, events, http, observability, storage, query)
- Add src/Spreadsheet/ engine with native POI backend and Excel bridge
- Add Envelope C spreadsheet flow and tender bidding envelope modules
- Consolidate components into src/components/, remove legacy Component/ and Hooks/
- Add registration state/validation, auth session guard, profile edit modules
- Add unit tests across axios, redux, bidding, registration, encryption, core
- Add CI workflow, docs (architecture, audit, DSC API spec), keystore example
- Remove tracked keystore binary; ignore scripts.zip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-07-16 10:20:22 +05:30
co-authored by Claude Fable 5
parent 9acec328a7
commit 1d7646d938
217 changed files with 11068 additions and 6855 deletions
Binary file not shown.
+44 -4
View File
@@ -72,7 +72,22 @@ def enableProguardInReleaseBuilds = false
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
/**
* Release signing credentials live in android/keystore.properties (gitignored).
* See android/keystore.properties.example. In CI, write the file from secrets
* before building. Local dev builds without it fall back to the debug key and
* MUST NOT be distributed.
*/
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
def hasReleaseSigning = keystorePropertiesFile.exists()
if (hasReleaseSigning) {
keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
} else {
logger.warn("WARNING: android/keystore.properties not found — release build will be DEBUG-SIGNED and must not be distributed.")
}
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
@@ -92,25 +107,50 @@ android {
keyAlias 'androiddebugkey'
keyPassword 'android'
}
if (hasReleaseSigning) {
release {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see http://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
signingConfig hasReleaseSigning ? signingConfigs.release : signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
packagingOptions {
resources {
excludes += [
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/license.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/notice.txt",
"META-INF/*.kotlin_module",
"META-INF/versions/**",
]
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
// Native Apache POI backend for metadata-preserving BOQ editing.
implementation("org.apache.poi:poi-ooxml:5.2.5")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
-2
View File
@@ -5,7 +5,6 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
@@ -16,7 +15,6 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true">
<activity
@@ -1,5 +1,7 @@
package com.procure
import android.os.Bundle
import android.view.WindowManager
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -7,13 +9,26 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// FLAG_SECURE: the app displays sealed-bid amounts, Aadhaar OTPs and other
// regulated PII. Block screenshots, screen recording and the app-switcher
// thumbnail in release builds; debug builds stay capturable for QA.
if (!BuildConfig.DEBUG) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}
}
/**
* Returns the name of the main component registered from JavaScript.
* Returns the name of the main component registered from JavaScript.
*/
override fun getMainComponentName(): String = "procure"
/**
* Returns the instance of the [ReactActivityDelegate].
* Returns the instance of the [ReactActivityDelegate].
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
@@ -11,6 +11,7 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.procure.excel.ExcelPackage
class MainApplication : Application(), ReactApplication {
@@ -18,8 +19,7 @@ class MainApplication : Application(), ReactApplication {
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(ExcelPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,407 @@
package com.procure.excel
import android.util.Base64
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.Cell
import org.apache.poi.ss.usermodel.CellType
import org.apache.poi.ss.usermodel.DataValidation
import org.apache.poi.ss.usermodel.DataValidationConstraint.ValidationType
import org.apache.poi.ss.usermodel.DateUtil
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.SheetVisibility
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFColor
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
/**
* ExcelModule — the native Apache POI bridge.
*
* Holds one workbook per session. `open` parses xlsx bytes and returns a
* WorkbookDTO (values, formulas, styles, merges, hidden lines, protection,
* lock flags, validations). `save` applies ONLY edited values back onto the
* retained workbook — preserving every other feature — and returns the new
* bytes. Formula recalculation is deferred to Excel/HyperFormula.
*/
class ExcelModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
private var workbook: Workbook? = null
override fun getName(): String = "ExcelModule"
/* ------------------------------- open ------------------------------- */
@ReactMethod
fun open(base64: String, promise: Promise) {
try {
close()
val bytes = Base64.decode(base64, Base64.DEFAULT)
val wb = XSSFWorkbook(ByteArrayInputStream(bytes))
workbook = wb
promise.resolve(serializeWorkbook(wb).toString())
} catch (e: Throwable) {
promise.reject("E_OPEN", "Failed to open workbook: ${e.message}", e)
}
}
private fun serializeWorkbook(wb: Workbook): JSONObject {
val root = JSONObject()
root.put("activeSheet", wb.activeSheetIndex.coerceAtLeast(0))
val sheets = JSONArray()
for (i in 0 until wb.numberOfSheets) {
sheets.put(serializeSheet(wb, i))
}
root.put("sheets", sheets)
return root
}
private fun serializeSheet(wb: Workbook, index: Int): JSONObject {
val sheet = wb.getSheetAt(index)
val out = JSONObject()
out.put("name", wb.getSheetName(index))
out.put("index", index)
out.put(
"state",
when (wb.getSheetVisibility(index)) {
SheetVisibility.HIDDEN -> "hidden"
SheetVisibility.VERY_HIDDEN -> "veryHidden"
else -> "visible"
},
)
out.put("protected", sheet.protect)
// Frozen panes.
var frozenRows = 0
var frozenCols = 0
try {
sheet.paneInformation?.let { pane ->
if (pane.isFreezePane) {
frozenRows = pane.horizontalSplitPosition.toInt()
frozenCols = pane.verticalSplitPosition.toInt()
}
}
} catch (_: Throwable) { /* ignore pane read errors */ }
out.put("frozenRows", frozenRows)
out.put("frozenCols", frozenCols)
val lastRow = sheet.lastRowNum
var maxCol = 0
val hiddenRows = JSONArray()
val rowHeights = JSONObject()
val cells = JSONArray()
// Precompute per-cell data validations.
val dvMap = buildValidationMap(sheet)
for (r in 0..lastRow) {
val row = sheet.getRow(r) ?: continue
if (row.zeroHeight) hiddenRows.put(r)
rowHeights.put(r.toString(), pointsToPx(row.heightInPoints))
for (cell in row) {
val c = cell.columnIndex
if (c > maxCol) maxCol = c
val dto = serializeCell(cell, dvMap["$r,$c"]) ?: continue
cells.put(dto)
}
}
val colCount = maxCol + 1
out.put("rowCount", lastRow + 1)
out.put("colCount", colCount)
val hiddenCols = JSONArray()
val colWidths = JSONObject()
for (c in 0 until colCount) {
if (sheet.isColumnHidden(c)) hiddenCols.put(c)
try {
colWidths.put(c.toString(), charUnitsToPx(sheet.getColumnWidth(c)))
} catch (_: Throwable) { /* default width */ }
}
out.put("hiddenRows", hiddenRows)
out.put("hiddenColumns", hiddenCols)
out.put("columnWidths", colWidths)
out.put("rowHeights", rowHeights)
val merges = JSONArray()
for (region: CellRangeAddress in sheet.mergedRegions) {
merges.put(region.formatAsString())
}
out.put("merges", merges)
out.put("cells", cells)
return out
}
private fun serializeCell(cell: Cell, dv: JSONObject?): JSONObject? {
val style = cell.cellStyle
val locked = style?.locked ?: true
val numFmt = style?.dataFormatString
val comment = try {
cell.cellComment?.string?.string
} catch (_: Throwable) {
null
}
val styleJson = serializeStyle(cell)
val isFormula = cell.cellType == CellType.FORMULA
var value: Any? = null
var formula: String? = null
var type = "text"
try {
when (cell.cellType) {
CellType.FORMULA -> {
formula = cell.cellFormula
when (cell.cachedFormulaResultType) {
CellType.NUMERIC -> {
value = cell.numericCellValue
type = if (DateUtil.isCellDateFormatted(cell)) "date" else typeFromNumFmt(numFmt)
}
CellType.BOOLEAN -> { value = cell.booleanCellValue; type = "boolean" }
CellType.STRING -> { value = cell.stringCellValue; type = "text" }
CellType.ERROR -> { value = "#ERROR!"; type = "text" }
else -> { value = null }
}
}
CellType.NUMERIC -> {
value = cell.numericCellValue
type = if (DateUtil.isCellDateFormatted(cell)) "date" else typeFromNumFmt(numFmt)
}
CellType.STRING -> { value = cell.stringCellValue; type = "text" }
CellType.BOOLEAN -> { value = cell.booleanCellValue; type = "boolean" }
CellType.ERROR -> { value = "#ERROR!"; type = "text" }
else -> { value = null }
}
} catch (_: Throwable) {
value = null
}
// Prefer a validation-derived dropdown type.
if (dv != null && dv.optString("kind") == "list") type = "dropdown"
val hasContent = value != null || formula != null
val hasMeta = styleJson != null || comment != null || dv != null || !locked
if (!hasContent && !hasMeta) return null
val obj = JSONObject()
obj.put("r", cell.rowIndex)
obj.put("c", cell.columnIndex)
obj.put("v", value ?: JSONObject.NULL)
obj.put("f", formula ?: JSONObject.NULL)
obj.put("t", type)
if (numFmt != null && numFmt != "General") obj.put("numFmt", numFmt)
obj.put("locked", locked)
if (comment != null) obj.put("comment", comment)
if (styleJson != null) obj.put("style", styleJson)
if (dv != null) obj.put("dv", dv)
return obj
}
private fun serializeStyle(cell: Cell): JSONObject? {
val style = cell.cellStyle ?: return null
val json = JSONObject()
var any = false
try {
val font = cell.sheet.workbook.getFontAt(style.fontIndexAsInt)
if (font.bold) { json.put("bold", true); any = true }
if (font.italic) { json.put("italic", true); any = true }
if (font.underline.toInt() != 0) { json.put("underline", true); any = true }
if (font.strikeout) { json.put("strike", true); any = true }
if (font.fontHeightInPoints.toInt() != 11) { json.put("fontSize", font.fontHeightInPoints.toInt()); any = true }
if (font is XSSFFont) {
font.xssfColor?.let { hexFromColor(it)?.let { hex -> json.put("fontColor", hex); any = true } }
}
} catch (_: Throwable) { /* font read best-effort */ }
try {
if (style is XSSFCellStyle) {
val fill = style.fillForegroundColorColor
if (fill is XSSFColor) hexFromColor(fill)?.let { json.put("bgColor", it); any = true }
}
} catch (_: Throwable) { /* fill read best-effort */ }
try {
when (style.alignment) {
HorizontalAlignment.LEFT -> json.put("hAlign", "left")
HorizontalAlignment.CENTER -> json.put("hAlign", "center")
HorizontalAlignment.RIGHT -> json.put("hAlign", "right")
HorizontalAlignment.JUSTIFY -> json.put("hAlign", "justify")
else -> {}
}
when (style.verticalAlignment) {
VerticalAlignment.TOP -> json.put("vAlign", "top")
VerticalAlignment.CENTER -> json.put("vAlign", "middle")
VerticalAlignment.BOTTOM -> json.put("vAlign", "bottom")
else -> {}
}
if (style.wrapText) json.put("wrap", true)
if (style.alignment != HorizontalAlignment.GENERAL || style.wrapText) any = true
} catch (_: Throwable) { /* alignment best-effort */ }
try {
if (style.borderTop != BorderStyle.NONE) { json.put("borderTop", true); any = true }
if (style.borderRight != BorderStyle.NONE) { json.put("borderRight", true); any = true }
if (style.borderBottom != BorderStyle.NONE) { json.put("borderBottom", true); any = true }
if (style.borderLeft != BorderStyle.NONE) { json.put("borderLeft", true); any = true }
} catch (_: Throwable) { /* border best-effort */ }
return if (any) json else null
}
private fun buildValidationMap(sheet: Sheet): Map<String, JSONObject> {
val map = HashMap<String, JSONObject>()
try {
for (dv: DataValidation in sheet.dataValidations) {
val constraint = dv.validationConstraint
val json = JSONObject()
when (constraint.validationType) {
ValidationType.LIST -> {
json.put("kind", "list")
val values = constraint.explicitListValues
if (values != null) {
val arr = JSONArray()
values.forEach { arr.put(it) }
json.put("list", arr)
}
}
ValidationType.INTEGER -> json.put("kind", "whole")
ValidationType.DECIMAL -> json.put("kind", "decimal")
ValidationType.TEXT_LENGTH -> json.put("kind", "textLength")
ValidationType.DATE -> json.put("kind", "date")
else -> continue
}
constraint.formula1?.toDoubleOrNull()?.let { json.put("min", it) }
constraint.formula2?.toDoubleOrNull()?.let { json.put("max", it) }
dv.errorBoxText?.let { json.put("errorMessage", it) }
for (region in dv.regions.cellRangeAddresses) {
for (r in region.firstRow..region.lastRow) {
for (c in region.firstColumn..region.lastColumn) {
map["$r,$c"] = json
}
}
}
}
} catch (_: Throwable) { /* validations best-effort */ }
return map
}
/* ------------------------------- save ------------------------------- */
@ReactMethod
fun save(saveRequestJson: String, promise: Promise) {
val wb = workbook
if (wb == null) {
promise.reject("E_NO_WORKBOOK", "No workbook is open.")
return
}
try {
val request = JSONObject(saveRequestJson)
val edits = request.optJSONArray("edits") ?: JSONArray()
for (i in 0 until edits.length()) {
applyEdit(wb, edits.getJSONObject(i))
}
// Ask consumers (Excel) to recalculate; HyperFormula already did in-app.
wb.setForceFormulaRecalculation(true)
val bos = ByteArrayOutputStream()
wb.write(bos)
val base64 = Base64.encodeToString(bos.toByteArray(), Base64.NO_WRAP)
promise.resolve(base64)
} catch (e: Throwable) {
promise.reject("E_SAVE", "Failed to save workbook: ${e.message}", e)
}
}
private fun applyEdit(wb: Workbook, edit: JSONObject) {
val sheetIndex = edit.getInt("sheet")
if (sheetIndex < 0 || sheetIndex >= wb.numberOfSheets) return
val sheet = wb.getSheetAt(sheetIndex)
val r = edit.getInt("r")
val c = edit.getInt("c")
val row: Row = sheet.getRow(r) ?: sheet.createRow(r)
val cell: Cell = row.getCell(c) ?: row.createCell(c)
when {
edit.optBoolean("clear", false) -> cell.setBlank()
!edit.isNull("f") -> cell.cellFormula = edit.getString("f")
else -> {
when (val v = edit.opt("v")) {
is Boolean -> cell.setCellValue(v)
is Int -> cell.setCellValue(v.toDouble())
is Long -> cell.setCellValue(v.toDouble())
is Double -> cell.setCellValue(v)
is String -> cell.setCellValue(v)
null, JSONObject.NULL -> cell.setBlank()
else -> cell.setCellValue(v.toString())
}
}
}
}
/* ------------------------------ close ------------------------------- */
@ReactMethod
fun close(promise: Promise) {
close()
promise.resolve(null)
}
private fun close() {
try {
workbook?.close()
} catch (_: Throwable) {
} finally {
workbook = null
}
}
/* ------------------------------ helpers ----------------------------- */
private fun hexFromColor(color: XSSFColor?): String? {
if (color == null) return null
return try {
val argb = color.argbHex ?: return null // "FFRRGGBB"
if (argb.length == 8) "#${argb.substring(2)}" else "#$argb"
} catch (_: Throwable) {
null
}
}
private fun typeFromNumFmt(fmt: String?): String {
if (fmt != null) {
val stripped = fmt.replace(Regex("\\[[^\\]]*\\]"), "").replace(Regex("\"[^\"]*\""), "")
if (fmt.contains("%")) return "percentage"
if (Regex("[dmy]", RegexOption.IGNORE_CASE).containsMatchIn(stripped) && !Regex("[#0]").containsMatchIn(stripped)) return "date"
if (Regex("[$€£₹¥]").containsMatchIn(fmt)) return "currency"
}
return "number"
}
private fun charUnitsToPx(width256: Int): Int {
val chars = width256 / 256.0
return (chars * 7.5 + 8).toInt().coerceIn(48, 420)
}
private fun pointsToPx(points: Float): Int {
return (points * 1.3333).toInt().coerceIn(28, 240)
}
}
@@ -0,0 +1,22 @@
package com.procure.excel
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
/**
* Registers [ExcelModule] with React Native. Added manually in
* MainApplication.getPackages(). Works under both the old and new architecture
* (the JS ExcelBridge resolves it via TurboModuleRegistry first, then the
* legacy NativeModules registry).
*/
class ExcelPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(ExcelModule(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}
@@ -1,6 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Allow ALL cleartext (http://) traffic for every build, including release.
Needed so the app can reach http://eproc-backend.lincpay/ over plain HTTP. -->
<!--
Cleartext (http://) is DENIED by default. It is permitted only for the
specific backend hosts below that are not yet served over HTTPS.
TODO(backend): once eproc-backend.lincpay terminates TLS, delete its
domain-config so the entire app is HTTPS-only, and add certificate pinning.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
<base-config cleartextTrafficPermitted="false" />
<!-- Production REST backend — currently HTTP-only. Remove when HTTPS lands. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">eproc-backend.lincpay</domain>
</domain-config>
<!-- Local development hosts (Metro / LAN backends / emulator loopback). -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">192.168.6.194</domain>
<domain includeSubdomains="false">192.168.29.226</domain>
</domain-config>
</network-security-config>
+1 -1
View File
@@ -1,7 +1,7 @@
buildscript {
ext {
buildToolsVersion = "35.0.0"
minSdkVersion = 24
minSdkVersion = 26
compileSdkVersion = 35
targetSdkVersion = 35
ndkVersion = "27.1.12297006"
+11
View File
@@ -0,0 +1,11 @@
# Copy to android/keystore.properties (gitignored) and fill in real values.
# The release keystore itself must live OUTSIDE the repository or be gitignored.
#
# IMPORTANT: the previous keystore (android/app/appKeyStore) was committed to
# git history — treat it as compromised. Generate a NEW keystore:
# keytool -genkeypair -v -storetype PKCS12 -keystore procurelinc-release.keystore \
# -alias procurelinc -keyalg RSA -keysize 2048 -validity 10000
storeFile=../procurelinc-release.keystore
storePassword=CHANGE_ME
keyAlias=procurelinc
keyPassword=CHANGE_ME